Compare commits
2 Commits
f39f73ce95
...
6146ac0063
Author | SHA1 | Date |
---|---|---|
Ben Visness | 6146ac0063 | |
ilidemi | 32db9b1843 |
|
@ -84,6 +84,8 @@ func UrlWithFragment(path string, query []Q, fragment string) string {
|
|||
return HMNProjectContext.UrlWithFragment(path, query, fragment)
|
||||
}
|
||||
|
||||
// Takes a project URL and rewrites it using the current URL context. This can be used
|
||||
// to convert a personal project URL to official and vice versa.
|
||||
func (c *UrlContext) RewriteProjectUrl(u *url.URL) string {
|
||||
// we need to strip anything matching the personal project regex to get the base path
|
||||
match := RegexPersonalProject.FindString(u.Path)
|
||||
|
|
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 122 KiB |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 74 KiB |
After Width: | Height: | Size: 89 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#EF9645" d="M4.861 9.147c.94-.657 2.357-.531 3.201.166l-.968-1.407c-.779-1.111-.5-2.313.612-3.093 1.112-.777 4.263 1.312 4.263 1.312-.786-1.122-.639-2.544.483-3.331 1.122-.784 2.67-.513 3.456.611l10.42 14.72L25 31l-11.083-4.042L4.25 12.625c-.793-1.129-.519-2.686.611-3.478z"/><path fill="#FFDC5D" d="M2.695 17.336s-1.132-1.65.519-2.781c1.649-1.131 2.78.518 2.78.518l5.251 7.658c.181-.302.379-.6.6-.894L4.557 11.21s-1.131-1.649.519-2.78c1.649-1.131 2.78.518 2.78.518l6.855 9.997c.255-.208.516-.417.785-.622L7.549 6.732s-1.131-1.649.519-2.78c1.649-1.131 2.78.518 2.78.518l7.947 11.589c.292-.179.581-.334.871-.498L12.238 4.729s-1.131-1.649.518-2.78c1.649-1.131 2.78.518 2.78.518l7.854 11.454 1.194 1.742c-4.948 3.394-5.419 9.779-2.592 13.902.565.825 1.39.26 1.39.26-3.393-4.949-2.357-10.51 2.592-13.903L24.515 8.62s-.545-1.924 1.378-2.47c1.924-.545 2.47 1.379 2.47 1.379l1.685 5.004c.668 1.984 1.379 3.961 2.32 5.831 2.657 5.28 1.07 11.842-3.94 15.279-5.465 3.747-12.936 2.354-16.684-3.11L2.695 17.336z"/><g fill="#5DADEC"><path d="M12 32.042C8 32.042 3.958 28 3.958 24c0-.553-.405-1-.958-1s-1.042.447-1.042 1C1.958 30 6 34.042 12 34.042c.553 0 1-.489 1-1.042s-.447-.958-1-.958z"/><path d="M7 34c-3 0-5-2-5-5 0-.553-.447-1-1-1s-1 .447-1 1c0 4 3 7 7 7 .553 0 1-.447 1-1s-.447-1-1-1zM24 2c-.552 0-1 .448-1 1s.448 1 1 1c4 0 8 3.589 8 8 0 .552.448 1 1 1s1-.448 1-1c0-5.514-4-10-10-10z"/><path d="M29 .042c-.552 0-1 .406-1 .958s.448 1.042 1 1.042c3 0 4.958 2.225 4.958 4.958 0 .552.489 1 1.042 1s.958-.448.958-1C35.958 3.163 33 .042 29 .042z"/></g></svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#EF9645" d="M23.216 20.937l-1.721-6.86-3.947-8.816c-.502-1.297.143-2.756 1.44-3.257 1.296-.506 2.756.143 3.258 1.44l6.203 15.769-5.233 1.724z"/><path fill="#FFDC5D" d="M31.565 18.449c-.488-2.581-1.988-6.523-1.988-6.523L23.79 1.437C23.164.195 21.648-.303 20.407.322c-1.242.626-1.742 2.141-1.115 3.383l5.33 9.547c.013.022 1.413 5.491 1.413 5.491-1.078-.995-2.607-2.359-4.015-3.618-3.098-2.772-4.936-3.811-4.936-3.811-.71-.443-1.179-.506-2.132-.059L9.08 13.823c-.157.078-.29.188-.395.329l-2.313 3.086c-.893 1.067-.752 2.655.315 3.547 1.066.893 2.653.75 3.548-.314.048-.058 1.78-2.56 1.936-2.64 1.037-.533 2.965-1.447 3.808-1.42.897.029 6.281 5.957 6.281 5.957.206.259.23.618.06.902l-2.915 5.228c-.079.131-.193.236-.33.303l-2.674 1.5c-.154.075-.328.099-.496.067l-5.27-2.272c-.262-.113-.48-.32-.592-.583-.787-1.85-.898-3.619-.899-3.639-.065-1.39-1.244-2.463-2.634-2.398-1.387.056-2.463 1.243-2.398 2.633.013.263.351 5.64 4.727 9.292 2.528 2.108 5.654 2.924 9.649 2.387 4.612-.619 7.469-1.233 11.506-9.558 1.117-2.305 1.903-6.024 1.571-7.781z"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFDB5E" d="M34.956 17.916c0-.503-.12-.975-.321-1.404-1.341-4.326-7.619-4.01-16.549-4.221-1.493-.035-.639-1.798-.115-5.668.341-2.517-1.282-6.382-4.01-6.382-4.498 0-.171 3.548-4.148 12.322-2.125 4.688-6.875 2.062-6.875 6.771v10.719c0 1.833.18 3.595 2.758 3.885C8.195 34.219 7.633 36 11.238 36h18.044c1.838 0 3.333-1.496 3.333-3.334 0-.762-.267-1.456-.698-2.018 1.02-.571 1.72-1.649 1.72-2.899 0-.76-.266-1.454-.696-2.015 1.023-.57 1.725-1.649 1.725-2.901 0-.909-.368-1.733-.961-2.336.757-.611 1.251-1.535 1.251-2.581z"/><path fill="#EE9547" d="M23.02 21.249h8.604c1.17 0 2.268-.626 2.866-1.633.246-.415.109-.952-.307-1.199-.415-.247-.952-.108-1.199.307-.283.479-.806.775-1.361.775h-8.81c-.873 0-1.583-.71-1.583-1.583s.71-1.583 1.583-1.583H28.7c.483 0 .875-.392.875-.875s-.392-.875-.875-.875h-5.888c-1.838 0-3.333 1.495-3.333 3.333 0 1.025.475 1.932 1.205 2.544-.615.605-.998 1.445-.998 2.373 0 1.028.478 1.938 1.212 2.549-.611.604-.99 1.441-.99 2.367 0 1.12.559 2.108 1.409 2.713-.524.589-.852 1.356-.852 2.204 0 1.838 1.495 3.333 3.333 3.333h5.484c1.17 0 2.269-.625 2.867-1.632.247-.415.11-.952-.305-1.199-.416-.245-.953-.11-1.199.305-.285.479-.808.776-1.363.776h-5.484c-.873 0-1.583-.71-1.583-1.583s.71-1.583 1.583-1.583h6.506c1.17 0 2.27-.626 2.867-1.633.247-.416.11-.953-.305-1.199-.419-.251-.954-.11-1.199.305-.289.487-.799.777-1.363.777h-7.063c-.873 0-1.583-.711-1.583-1.584s.71-1.583 1.583-1.583h8.091c1.17 0 2.269-.625 2.867-1.632.247-.415.11-.952-.305-1.199-.417-.246-.953-.11-1.199.305-.289.486-.799.776-1.363.776H23.02c-.873 0-1.583-.71-1.583-1.583s.709-1.584 1.583-1.584z"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M28.457 17.797c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.145.591.175.142.426.147.61.014.012-.009 1.262-.902 3.702-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.177-.142.238-.386.145-.594zm-12 0c-.06-.135-1.499-3.297-4.457-3.297-2.957 0-4.397 3.162-4.457 3.297-.092.207-.032.449.144.591.176.142.427.147.61.014.013-.009 1.262-.902 3.703-.902 2.426 0 3.674.881 3.702.901.088.066.194.099.298.099.11 0 .221-.037.312-.109.178-.142.237-.386.145-.594zM31 16c-.396 0-.772-.238-.929-.629-1.778-4.445-6.223-5.381-6.268-5.391-.541-.108-.893-.635-.784-1.177.108-.542.635-.891 1.177-.784.226.045 5.556 1.168 7.732 6.608.205.513-.045 1.095-.558 1.3-.12.05-.246.073-.37.073zM5 16c-.124 0-.249-.023-.371-.072-.513-.205-.762-.787-.557-1.3 2.176-5.44 7.506-6.563 7.732-6.608.543-.106 1.068.243 1.177.784.108.54-.242 1.066-.781 1.176-.185.038-4.506.98-6.271 5.391-.157.391-.533.629-.929.629zm13 6c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"/><path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z"/><path fill="#5DADEC" d="M10.847 28.229c-.68 2.677-3.4 4.295-6.077 3.615-2.676-.679-4.295-3.399-3.616-6.076.679-2.677 6.337-8.708 7.307-8.462.97.247 3.065 8.247 2.386 10.923zm14.286 0c.68 2.677 3.4 4.295 6.077 3.615 2.677-.679 4.296-3.399 3.616-6.076-.68-2.677-6.338-8.708-7.308-8.462-.968.247-3.064 8.247-2.385 10.923z"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M18 22c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"/><path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-2 4-9 4-9-4-9-4z"/><path fill="#664500" d="M6.001 20c-.304 0-.604-.138-.801-.4-.332-.441-.242-1.068.2-1.399.143-.107 2.951-2.183 6.856-2.933C9.781 14.027 7.034 14 6.999 14c-.552-.002-.999-.45-.998-1.002 0-.551.447-.998.999-.998.221 0 5.452.038 8.707 3.293.286.286.372.716.217 1.09-.155.374-.52.617-.924.617-4.613 0-8.363 2.772-8.4 2.8-.18.135-.391.2-.599.2zm23.998-.001c-.208 0-.418-.064-.598-.198C29.363 19.772 25.59 17 21 17c-.404 0-.77-.243-.924-.617-.155-.374-.069-.804.217-1.09C23.549 12.038 28.779 12 29 12c.552 0 .998.447.999.998.001.552-.446 1-.997 1.002-.036 0-2.783.027-5.258 1.268 3.905.75 6.713 2.825 6.855 2.933.441.331.531.956.201 1.398-.196.261-.496.4-.801.4z"/></svg>
|
After Width: | Height: | Size: 1.0 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><ellipse fill="#664500" cx="12" cy="13.5" rx="2.5" ry="3.5"/><ellipse fill="#664500" cx="24" cy="13.5" rx="2.5" ry="3.5"/><path fill="#FFF" d="M25 21c2.209 0 4 1.791 4 4s-1.791 4-4 4H11c-2.209 0-4-1.791-4-4s1.791-4 4-4h14z"/><path fill="#664500" d="M25 20H11c-2.757 0-5 2.243-5 5s2.243 5 5 5h14c2.757 0 5-2.243 5-5s-2.243-5-5-5zm0 2c1.483 0 2.71 1.084 2.949 2.5H24.5V22h.5zm-1.5 0v2.5h-3V22h3zm-4 0v2.5h-3V22h3zm-4 0v2.5h-3V22h3zM11 22h.5v2.5H8.051C8.29 23.084 9.517 22 11 22zm0 6c-1.483 0-2.71-1.084-2.949-2.5H11.5V28H11zm1.5 0v-2.5h3V28h-3zm4 0v-2.5h3V28h-3zm4 0v-2.5h3V28h-3zm4.5 0h-.5v-2.5h3.449C27.71 26.916 26.483 28 25 28z"/></svg>
|
After Width: | Height: | Size: 817 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#FFCC4D" cx="18" cy="18" r="18"/><circle fill="#F4F7F9" cx="24.5" cy="13.5" r="5.5"/><circle fill="#F4F7F9" cx="11.5" cy="13.5" r="5.5"/><path fill="#65471B" d="M23.109 23.424c-2.763-.667-8.873-.06-11.162 4.405-.082.158-.04.353.1.464.068.055.151.082.234.082.085 0 .171-.029.241-.087 3.084-2.58 7.436-2.58 10.036-2.58 1.635 0 2.536 0 2.536-.708s-.705-1.268-1.985-1.576zM10.5 13c1.381 0 2.5-1.119 2.5-2.5 0-1.252-.923-2.28-2.124-2.462-.79.089-1.526.348-2.178.736C8.268 9.223 8 9.829 8 10.5c0 1.381 1.119 2.5 2.5 2.5zm13 0c1.381 0 2.5-1.119 2.5-2.5 0-1.252-.923-2.28-2.124-2.462-.789.089-1.526.348-2.177.736C21.268 9.223 21 9.829 21 10.5c0 1.381 1.119 2.5 2.5 2.5z"/></svg>
|
After Width: | Height: | Size: 744 B |
After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 9.6 KiB |
After Width: | Height: | Size: 6.2 KiB |
After Width: | Height: | Size: 8.7 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 148 KiB |
After Width: | Height: | Size: 220 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#DA2F47" d="M4.042 27.916c4.89.551 9.458-1.625 13.471-5.946 4.812-5.182 5-13 5-14s11.31-3.056 11 5c-.43 11.196-7.43 20.946-19.917 21.916-5.982.465-9.679-.928-11.387-2.345-2.69-2.231-.751-4.916 1.833-4.625z"/><path fill="#77B255" d="M30.545 6.246c.204-1.644.079-3.754-.747-4.853-1.111-1.479-4.431-.765-3.569.113.96.979 2.455 2.254 2.401 4.151-.044-.01-.085-.022-.13-.032-3.856-.869-6.721 1.405-7.167 2.958-.782 2.722 4.065.568 4.68 1.762 1.82 3.53 3.903.155 4.403 1.28s4.097 4.303 4.097.636c0-3.01-1.192-4.903-3.968-6.015z"/></svg>
|
After Width: | Height: | Size: 602 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><ellipse fill="#66471B" cx="11.5" cy="14.5" rx="2.5" ry="3.5"/><ellipse fill="#66471B" cx="24.5" cy="14.5" rx="2.5" ry="3.5"/><path fill="#66471B" d="M7 21.262c0 3.964 4.596 9 11 9s11-5 11-9c0 0-10.333 2.756-22 0z"/><path fill="#E8596E" d="M18.545 23.604l-1.091-.005c-3.216-.074-5.454-.596-5.454-.596v6.961c0 3 2 6 6 6s6-3 6-6v-6.92c-1.922.394-3.787.542-5.455.56z"/><path fill="#DD2F45" d="M18 31.843c.301 0 .545-.244.545-.545v-7.694l-1.091-.005v7.699c.001.301.245.545.546.545z"/></svg>
|
After Width: | Height: | Size: 665 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><ellipse fill="#664500" cx="11.5" cy="16.5" rx="2.5" ry="3.5"/><ellipse fill="#664500" cx="24.5" cy="16.5" rx="2.5" ry="3.5"/><path fill="#664500" d="M23.485 27.879C23.474 27.835 22.34 23.5 18 23.5s-5.474 4.335-5.485 4.379c-.053.213.044.431.232.544.188.112.433.086.596-.06.009-.008 1.013-.863 4.657-.863 3.59 0 4.617.83 4.656.863.095.09.219.137.344.137.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557z"/></svg>
|
After Width: | Height: | Size: 593 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#FFCC4D" cx="18" cy="18" r="18"/><path fill="#664500" d="M10.515 23.621C10.56 23.8 11.683 28 18 28c6.318 0 7.44-4.2 7.485-4.379.055-.217-.043-.442-.237-.554-.195-.111-.439-.078-.6.077C24.629 23.163 22.694 25 18 25s-6.63-1.837-6.648-1.855C11.256 23.05 11.128 23 11 23c-.084 0-.169.021-.246.064-.196.112-.294.339-.239.557z"/><ellipse fill="#664500" cx="12" cy="13.5" rx="2.5" ry="3.5"/><ellipse fill="#664500" cx="24" cy="13.5" rx="2.5" ry="3.5"/></svg>
|
After Width: | Height: | Size: 525 B |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><circle fill="#FFCB4C" cx="18" cy="17.018" r="17"/><path fill="#65471B" d="M14.524 21.036c-.145-.116-.258-.274-.312-.464-.134-.46.13-.918.59-1.021 4.528-1.021 7.577 1.363 7.706 1.465.384.306.459.845.173 1.205-.286.358-.828.401-1.211.097-.11-.084-2.523-1.923-6.182-1.098-.274.061-.554-.016-.764-.184z"/><ellipse fill="#65471B" cx="13.119" cy="11.174" rx="2.125" ry="2.656"/><ellipse fill="#65471B" cx="24.375" cy="12.236" rx="2.125" ry="2.656"/><path fill="#F19020" d="M17.276 35.149s1.265-.411 1.429-1.352c.173-.972-.624-1.167-.624-1.167s1.041-.208 1.172-1.376c.123-1.101-.861-1.363-.861-1.363s.97-.4 1.016-1.539c.038-.959-.995-1.428-.995-1.428s5.038-1.221 5.556-1.341c.516-.12 1.32-.615 1.069-1.694-.249-1.08-1.204-1.118-1.697-1.003-.494.115-6.744 1.566-8.9 2.068l-1.439.334c-.54.127-.785-.11-.404-.512.508-.536.833-1.129.946-2.113.119-1.035-.232-2.313-.433-2.809-.374-.921-1.005-1.649-1.734-1.899-1.137-.39-1.945.321-1.542 1.561.604 1.854.208 3.375-.833 4.293-2.449 2.157-3.588 3.695-2.83 6.973.828 3.575 4.377 5.876 7.952 5.048l3.152-.681z"/><path fill="#65471B" d="M9.296 6.351c-.164-.088-.303-.224-.391-.399-.216-.428-.04-.927.393-1.112 4.266-1.831 7.699-.043 7.843.034.433.231.608.747.391 1.154-.216.405-.74.546-1.173.318-.123-.063-2.832-1.432-6.278.047-.257.109-.547.085-.785-.042zm12.135 3.75c-.156-.098-.286-.243-.362-.424-.187-.442.023-.927.468-1.084 4.381-1.536 7.685.48 7.823.567.415.26.555.787.312 1.178-.242.39-.776.495-1.191.238-.12-.072-2.727-1.621-6.267-.379-.266.091-.553.046-.783-.096z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -94,7 +94,7 @@ I don't think code needs to be skimmable at all and we should try to avoid skimm
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:01 AM</span>
|
||||
|
@ -322,7 +322,7 @@ I don't think code needs to be skimmable at all and we should try to avoid skimm
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:03 AM</span>
|
||||
|
@ -725,7 +725,7 @@ Much of today's code readability ideas, of having small classes/functions/files,
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:07 AM</span>
|
||||
|
@ -1085,7 +1085,7 @@ You would skim the code to find your place, but you wouldn't think you understan
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:09 AM</span>
|
||||
|
@ -1115,7 +1115,7 @@ You would skim the code to find your place, but you wouldn't think you understan
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:13 AM</span>
|
||||
|
@ -1870,7 +1870,7 @@ where are you going with the LOD terminology <span class="chatlog__markdown-ment
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:18 AM</span>
|
||||
|
@ -2516,7 +2516,7 @@ i think this is often why people have syntax highlighting in their code, it help
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:21 AM</span>
|
||||
|
@ -2832,7 +2832,7 @@ I just want to point out that skimmability is bad only when you think you unders
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:24 AM</span>
|
||||
|
@ -3184,7 +3184,7 @@ You think you understand it, you make changes based on that understanding, and y
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:30 AM</span>
|
||||
|
@ -3434,7 +3434,7 @@ and understand what the piece of code does without cheking the signs and stuff.
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:34 AM</span>
|
||||
|
@ -3643,7 +3643,7 @@ and understand what the piece of code does without cheking the signs and stuff.
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:35 AM</span>
|
||||
|
@ -3673,7 +3673,7 @@ and understand what the piece of code does without cheking the signs and stuff.
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:36 AM</span>
|
||||
|
@ -4005,7 +4005,7 @@ If you compare that to more OOPy implementations of the same thing, those tend t
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:39 AM</span>
|
||||
|
@ -4125,7 +4125,7 @@ If you compare that to more OOPy implementations of the same thing, those tend t
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:41 AM</span>
|
||||
|
@ -4271,7 +4271,7 @@ If you compare that to more OOPy implementations of the same thing, those tend t
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:42 AM</span>
|
||||
|
@ -4588,7 +4588,7 @@ If you compare that to more OOPy implementations of the same thing, those tend t
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:51 AM</span>
|
||||
|
@ -4715,7 +4715,7 @@ oh and another reason is probably being afraid of repeating oneself. If the same
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:53 AM</span>
|
||||
|
@ -4891,7 +4891,7 @@ That is a really good example of a situation where "skimmability" hurts. It's co
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:57 AM</span>
|
||||
|
@ -4951,7 +4951,7 @@ That is a really good example of a situation where "skimmability" hurts. It's co
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 10:58 AM</span>
|
||||
|
@ -5049,7 +5049,7 @@ That is a really good example of a situation where "skimmability" hurts. It's co
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:01 AM</span>
|
||||
|
@ -5204,7 +5204,7 @@ That is a really good example of a situation where "skimmability" hurts. It's co
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:04 AM</span>
|
||||
|
@ -5264,7 +5264,7 @@ That is a really good example of a situation where "skimmability" hurts. It's co
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:05 AM</span>
|
||||
|
@ -5754,7 +5754,7 @@ im not sure if it has been said, but often comments are an issue for skimmabilit
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:16 AM</span>
|
||||
|
@ -5936,7 +5936,7 @@ Less often than out of date names </span>
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:17 AM</span>
|
||||
|
@ -5970,7 +5970,7 @@ Less often than out of date names </span>
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:17 AM</span>
|
||||
|
@ -6030,7 +6030,7 @@ Wow, that's a special case. Huge comments, and tiny variable names.
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:18 AM</span>
|
||||
|
@ -6209,7 +6209,7 @@ Wow, that's a special case. Huge comments, and tiny variable names.
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:19 AM</span>
|
||||
|
@ -6249,7 +6249,7 @@ Yes. I have experience of naming every variable with underscores in my homework
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:20 AM</span>
|
||||
|
@ -6945,7 +6945,7 @@ Sometimes I just do <span class="chatlog__markdown-pre chatlog__markdown-pre--in
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:37 AM</span>
|
||||
|
@ -7064,7 +7064,7 @@ And the length of the variable name should probably match the length of the cont
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:38 AM</span>
|
||||
|
@ -7231,7 +7231,7 @@ And the length of the variable name should probably match the length of the cont
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:41 AM</span>
|
||||
|
@ -7369,7 +7369,7 @@ Since your mind will be actually into reading the code rather than skimming on t
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 11:43 AM</span>
|
||||
|
@ -7835,7 +7835,7 @@ Do you think future editors/system would dramatically change the way we approach
|
|||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 12:07 PM</span>
|
||||
|
@ -7924,7 +7924,7 @@ I think you should have very strict context switches between files
|
|||
</div>
|
||||
</div>
|
||||
<div class="chatlog__header">
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="456226577798135808">bumbread</span>
|
||||
<span class="chatlog__author" style="" title="bumbread27#9116" data-user-id="934740907385847858">bumbread</span>
|
||||
|
||||
|
||||
<span class="chatlog__timestamp">Jan 14, 2021 12:08 PM</span>
|
||||
|
|
|
@ -69,7 +69,7 @@ func AdminAtomFeed(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -134,7 +134,7 @@ type unapprovedUserData struct {
|
|||
|
||||
func AdminApprovalQueue(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -207,7 +207,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
userIds = append(userIds, u.User.ID)
|
||||
}
|
||||
|
||||
userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
userLinks, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -253,13 +253,13 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "User id can't be parsed")
|
||||
return c.RejectRequest("User id can't be parsed")
|
||||
}
|
||||
|
||||
user, err := hmndata.FetchUser(c.Context(), c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{})
|
||||
user, err := hmndata.FetchUser(c, c.Conn, c.CurrentUser, userId, hmndata.UsersQuery{})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return RejectRequest(c, "User not found")
|
||||
return c.RejectRequest("User not found")
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
|
||||
}
|
||||
|
@ -267,7 +267,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
whatHappened := ""
|
||||
if action == ApprovalQueueActionApprove {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
_, err := c.Conn.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -281,7 +281,7 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
whatHappened = fmt.Sprintf("%s approved successfully", user.Username)
|
||||
} else if action == ApprovalQueueActionSpammer {
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
_, err := c.Conn.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -293,15 +293,15 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
|
|||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to set user to banned"))
|
||||
}
|
||||
err = auth.DeleteSessionForUser(c.Context(), c.Conn, user.Username)
|
||||
err = auth.DeleteSessionForUser(c, c.Conn, user.Username)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
||||
}
|
||||
err = deleteAllPostsForUser(c.Context(), c.Conn, user.ID)
|
||||
err = deleteAllPostsForUser(c, c.Conn, user.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete spammer's posts"))
|
||||
}
|
||||
err = deleteAllProjectsForUser(c.Context(), c.Conn, user.ID)
|
||||
err = deleteAllProjectsForUser(c, c.Conn, user.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete spammer's projects"))
|
||||
}
|
||||
|
@ -324,7 +324,7 @@ type UnapprovedPost struct {
|
|||
}
|
||||
|
||||
func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
|
||||
posts, err := db.Query[UnapprovedPost](c.Context(), c.Conn,
|
||||
posts, err := db.Query[UnapprovedPost](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -355,7 +355,7 @@ type UnapprovedProject struct {
|
|||
}
|
||||
|
||||
func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
||||
ownerIDs, err := db.QueryScalar[int](c.Context(), c.Conn,
|
||||
ownerIDs, err := db.QueryScalar[int](c, c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM
|
||||
|
@ -369,7 +369,7 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
|||
return nil, oops.New(err, "failed to fetch unapproved users")
|
||||
}
|
||||
|
||||
projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
projects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: ownerIDs,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
|
@ -382,7 +382,7 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
|
|||
projectIDs = append(projectIDs, p.Project.ID)
|
||||
}
|
||||
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
projectLinks, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
|
|
@ -19,7 +19,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
|||
requestedUsername := usernameArgs[0]
|
||||
found = true
|
||||
c.Perf.StartBlock("SQL", "Fetch user")
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
user, err := db.QueryOne[models.User](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -45,7 +45,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
|
|||
|
||||
var res ResponseData
|
||||
res.Header().Set("Content-Type", "application/json")
|
||||
AddCORSHeaders(c, &res)
|
||||
addCORSHeaders(c, &res)
|
||||
if found {
|
||||
res.Write([]byte(fmt.Sprintf(`{ "found": true, "canonical": "%s" }`, canonicalUsername)))
|
||||
} else {
|
||||
|
|
|
@ -85,7 +85,7 @@ func AssetUpload(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
asset, err := assets.Create(c.Context(), c.Conn, assets.CreateInput{
|
||||
asset, err := assets.Create(c, c.Conn, assets.CreateInput{
|
||||
Content: data,
|
||||
Filename: originalFilename,
|
||||
ContentType: mimeType,
|
||||
|
|
|
@ -28,7 +28,7 @@ type LoginPageData struct {
|
|||
|
||||
func LoginPage(c *RequestContext) ResponseData {
|
||||
if c.CurrentUser != nil {
|
||||
return RejectRequest(c, "You are already logged in.")
|
||||
return c.RejectRequest("You are already logged in.")
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
|
@ -75,7 +75,7 @@ func Login(c *RequestContext) ResponseData {
|
|||
return res
|
||||
}
|
||||
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
user, err := db.QueryOne[models.User](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
|
@ -102,7 +102,7 @@ func Login(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if user.Status == models.UserStatusInactive {
|
||||
return RejectRequest(c, "You must validate your email address before logging in. You should've received an email shortly after registration. If you did not receive the email, please contact the staff.")
|
||||
return c.RejectRequest("You must validate your email address before logging in. You should've received an email shortly after registration. If you did not receive the email, please contact the staff.")
|
||||
}
|
||||
|
||||
res := c.Redirect(redirect, http.StatusSeeOther)
|
||||
|
@ -136,7 +136,7 @@ func RegisterNewUser(c *RequestContext) ResponseData {
|
|||
|
||||
func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
||||
if c.CurrentUser != nil {
|
||||
return RejectRequest(c, "Can't register new user. You are already logged in")
|
||||
return c.RejectRequest("Can't register new user. You are already logged in")
|
||||
}
|
||||
c.Req.ParseForm()
|
||||
|
||||
|
@ -146,16 +146,16 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
password := c.Req.Form.Get("password")
|
||||
password2 := c.Req.Form.Get("password2")
|
||||
if !UsernameRegex.Match([]byte(username)) {
|
||||
return RejectRequest(c, "Invalid username")
|
||||
return c.RejectRequest("Invalid username")
|
||||
}
|
||||
if !email.IsEmail(emailAddress) {
|
||||
return RejectRequest(c, "Invalid email address")
|
||||
return c.RejectRequest("Invalid email address")
|
||||
}
|
||||
if len(password) < 8 {
|
||||
return RejectRequest(c, "Password too short")
|
||||
return c.RejectRequest("Password too short")
|
||||
}
|
||||
if password != password2 {
|
||||
return RejectRequest(c, "Password confirmation doesn't match password")
|
||||
return c.RejectRequest("Password confirmation doesn't match password")
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Check blacklist")
|
||||
|
@ -169,7 +169,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
|
||||
userAlreadyExists := true
|
||||
_, err := db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
_, err := db.QueryOneScalar[int](c, c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM hmn_user
|
||||
|
@ -186,11 +186,11 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if userAlreadyExists {
|
||||
return RejectRequest(c, fmt.Sprintf("Username (%s) already exists.", username))
|
||||
return c.RejectRequest(fmt.Sprintf("Username (%s) already exists.", username))
|
||||
}
|
||||
|
||||
emailAlreadyExists := true
|
||||
_, err = db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
_, err = db.QueryOneScalar[int](c, c.Conn,
|
||||
`
|
||||
SELECT id
|
||||
FROM hmn_user
|
||||
|
@ -215,16 +215,16 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
hashed := auth.HashPassword(password)
|
||||
|
||||
c.Perf.StartBlock("SQL", "Create user and one time token")
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
var newUserId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
err = tx.QueryRow(c,
|
||||
`
|
||||
INSERT INTO hmn_user (username, email, password, date_joined, name, registration_ip)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
|
@ -237,7 +237,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
ott := models.GenerateToken()
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
|
||||
VALUES($1, $2, $3, $4, $5)
|
||||
|
@ -263,7 +263,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Commit user")
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit user to the db"))
|
||||
}
|
||||
|
@ -302,7 +302,7 @@ func EmailConfirmation(c *RequestContext) ResponseData {
|
|||
|
||||
username, hasUsername := c.PathParams["username"]
|
||||
if !hasUsername {
|
||||
return RejectRequest(c, "Bad validation url")
|
||||
return c.RejectRequest("Bad validation url")
|
||||
}
|
||||
|
||||
token := ""
|
||||
|
@ -319,7 +319,7 @@ func EmailConfirmation(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if !hasToken {
|
||||
return RejectRequest(c, "Bad validation url")
|
||||
return c.RejectRequest("Bad validation url")
|
||||
}
|
||||
|
||||
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypeRegistration)
|
||||
|
@ -366,13 +366,13 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Updating user status and deleting token")
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -385,7 +385,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
|
||||
}
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM one_time_token WHERE id = $1
|
||||
`,
|
||||
|
@ -395,7 +395,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete one time token"))
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit transaction"))
|
||||
}
|
||||
|
@ -413,7 +413,7 @@ func EmailConfirmationSubmit(c *RequestContext) ResponseData {
|
|||
// NOTE(asaf): Only call this when validationResult.Match is false.
|
||||
func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, validationResult validateUserAndTokenResult) ResponseData {
|
||||
if validationResult.User == nil {
|
||||
return RejectRequest(c, "You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
|
||||
return c.RejectRequest("You haven't validated your email in time and your user was deleted. You may try registering again with the same username.")
|
||||
}
|
||||
|
||||
if validationResult.OneTimeToken == nil {
|
||||
|
@ -422,7 +422,7 @@ func makeResponseForBadRegistrationTokenValidationResult(c *RequestContext, vali
|
|||
return c.Redirect(hmnurl.BuildLoginPage(""), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return RejectRequest(c, "Bad token. If you are having problems registering or logging in, please contact the staff.")
|
||||
return c.RejectRequest("Bad token. If you are having problems registering or logging in, please contact the staff.")
|
||||
}
|
||||
|
||||
// NOTE(asaf): PasswordReset refers specifically to "forgot your password" flow over email,
|
||||
|
@ -446,14 +446,14 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
emailAddress := strings.TrimSpace(c.Req.Form.Get("email"))
|
||||
|
||||
if username == "" && emailAddress == "" {
|
||||
return RejectRequest(c, "You must provide a username and an email address.")
|
||||
return c.RejectRequest("You must provide a username and an email address.")
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching user")
|
||||
type userQuery struct {
|
||||
User models.User `db:"hmn_user"`
|
||||
}
|
||||
user, err := db.QueryOne[models.User](c.Context(), c.Conn,
|
||||
user, err := db.QueryOne[models.User](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
|
@ -473,7 +473,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
if user != nil {
|
||||
c.Perf.StartBlock("SQL", "Fetching existing token")
|
||||
resetToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
|
||||
resetToken, err := db.QueryOne[models.OneTimeToken](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM one_time_token
|
||||
|
@ -495,7 +495,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
if resetToken != nil {
|
||||
if resetToken.Expires.Before(now.Add(time.Minute * 30)) { // NOTE(asaf): Expired or about to expire
|
||||
c.Perf.StartBlock("SQL", "Deleting expired token")
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
DELETE FROM one_time_token
|
||||
WHERE id = $1
|
||||
|
@ -512,7 +512,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
if resetToken == nil {
|
||||
c.Perf.StartBlock("SQL", "Creating new token")
|
||||
newToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
|
||||
newToken, err := db.QueryOne[models.OneTimeToken](c, c.Conn,
|
||||
`
|
||||
INSERT INTO one_time_token (token_type, created, expires, token_content, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
|
@ -567,12 +567,12 @@ func DoPasswordReset(c *RequestContext) ResponseData {
|
|||
token, hasToken := c.PathParams["token"]
|
||||
|
||||
if !hasToken || !hasUsername {
|
||||
return RejectRequest(c, "Bad validation url.")
|
||||
return c.RejectRequest("Bad validation url.")
|
||||
}
|
||||
|
||||
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
|
||||
if !validationResult.Match {
|
||||
return RejectRequest(c, "Bad validation url.")
|
||||
return c.RejectRequest("Bad validation url.")
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
|
@ -601,30 +601,30 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
validationResult := validateUsernameAndToken(c, username, token, models.TokenTypePasswordReset)
|
||||
if !validationResult.Match {
|
||||
return RejectRequest(c, "Bad validation url.")
|
||||
return c.RejectRequest("Bad validation url.")
|
||||
}
|
||||
|
||||
if c.CurrentUser != nil && c.CurrentUser.ID != validationResult.User.ID {
|
||||
return RejectRequest(c, fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username))
|
||||
return c.RejectRequest(fmt.Sprintf("Can't change password for %s. You are logged in as %s.", username, c.CurrentUser.Username))
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
return RejectRequest(c, "Password too short")
|
||||
return c.RejectRequest("Password too short")
|
||||
}
|
||||
if password != password2 {
|
||||
return RejectRequest(c, "Password confirmation doesn't match password")
|
||||
return c.RejectRequest("Password confirmation doesn't match password")
|
||||
}
|
||||
|
||||
hashed := auth.HashPassword(password)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
tag, err := tx.Exec(c.Context(),
|
||||
tag, err := tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET password = $1
|
||||
|
@ -638,7 +638,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if validationResult.User.Status == models.UserStatusInactive {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -652,7 +652,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM one_time_token
|
||||
WHERE id = $1
|
||||
|
@ -663,7 +663,7 @@ func DoPasswordResetSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete onetimetoken"))
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit password reset to the db"))
|
||||
}
|
||||
|
@ -698,7 +698,7 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro
|
|||
// re-hash and save the user's password if necessary
|
||||
if hashed.IsOutdated() {
|
||||
newHashed := auth.HashPassword(password)
|
||||
err := auth.UpdatePassword(c.Context(), c.Conn, user.Username, newHashed)
|
||||
err := auth.UpdatePassword(c, c.Conn, user.Username, newHashed)
|
||||
if err != nil {
|
||||
c.Logger.Error().Err(err).Msg("failed to update user's password")
|
||||
}
|
||||
|
@ -711,15 +711,15 @@ func tryLogin(c *RequestContext, user *models.User, password string) (bool, erro
|
|||
func loginUser(c *RequestContext, user *models.User, responseData *ResponseData) error {
|
||||
c.Perf.StartBlock("SQL", "Setting last login and creating session")
|
||||
defer c.Perf.EndBlock()
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to start db transaction")
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET last_login = $1
|
||||
|
@ -732,12 +732,12 @@ func loginUser(c *RequestContext, user *models.User, responseData *ResponseData)
|
|||
return oops.New(err, "failed to update last_login for user")
|
||||
}
|
||||
|
||||
session, err := auth.CreateSession(c.Context(), c.Conn, user.Username)
|
||||
session, err := auth.CreateSession(c, c.Conn, user.Username)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create session")
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to commit transaction")
|
||||
}
|
||||
|
@ -749,7 +749,7 @@ func logoutUser(c *RequestContext, res *ResponseData) {
|
|||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||
if err == nil {
|
||||
// clear the session from the db immediately, no expiration
|
||||
err := auth.DeleteSession(c.Context(), c.Conn, sessionCookie.Value)
|
||||
err := auth.DeleteSession(c, c.Conn, sessionCookie.Value)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("failed to delete session on logout")
|
||||
}
|
||||
|
@ -772,7 +772,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
|
|||
User models.User `db:"hmn_user"`
|
||||
OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
|
||||
}
|
||||
data, err := db.QueryOne[userAndTokenQuery](c.Context(), c.Conn,
|
||||
data, err := db.QueryOne[userAndTokenQuery](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM hmn_user
|
||||
|
|
|
@ -37,7 +37,7 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
|
||||
const postsPerPage = 20
|
||||
|
||||
numThreads, err := hmndata.CountThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
numThreads, err := hmndata.CountThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -51,7 +51,7 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
return c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
threads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
threads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
Limit: postsPerPage,
|
||||
|
@ -78,7 +78,7 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
canCreate := false
|
||||
if c.CurrentProject.HasBlog() && c.CurrentUser != nil {
|
||||
isProjectOwner := false
|
||||
owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||
owners, err := hmndata.FetchProjectOwners(c, c.Conn, c.CurrentProject.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
|
||||
}
|
||||
|
@ -128,7 +128,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
thread, posts, err := hmndata.FetchThreadPosts(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, hmndata.PostsQuery{
|
||||
thread, posts, err := hmndata.FetchThreadPosts(c, c.Conn, c.CurrentUser, cd.ThreadID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -155,7 +155,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
// Update thread last read info
|
||||
if c.CurrentUser != nil {
|
||||
c.Perf.StartBlock("SQL", "Update TLRI")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
_, err := c.Conn.Exec(c,
|
||||
`
|
||||
INSERT INTO thread_last_read_info (thread_id, user_id, lastread)
|
||||
VALUES ($1, $2, $3)
|
||||
|
@ -196,7 +196,7 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
thread, err := hmndata.FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, hmndata.ThreadsQuery{
|
||||
thread, err := hmndata.FetchThread(c, c.Conn, c.CurrentUser, cd.ThreadID, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -227,11 +227,11 @@ func BlogNewThread(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func BlogNewThreadSubmit(c *RequestContext) ResponseData {
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
err = c.Req.ParseForm()
|
||||
if err != nil {
|
||||
|
@ -240,15 +240,15 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
title := c.Req.Form.Get("title")
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
if title == "" {
|
||||
return RejectRequest(c, "You must provide a title for your post.")
|
||||
return c.RejectRequest("You must provide a title for your post.")
|
||||
}
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "You must provide a body for your post.")
|
||||
return c.RejectRequest("You must provide a body for your post.")
|
||||
}
|
||||
|
||||
// Create thread
|
||||
var threadId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
err = tx.QueryRow(c,
|
||||
`
|
||||
INSERT INTO thread (title, type, project_id, first_id, last_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
|
@ -265,9 +265,9 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// Create everything else
|
||||
hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
|
||||
hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, threadId, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post"))
|
||||
}
|
||||
|
@ -282,11 +282,11 @@ func BlogPostEdit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -326,17 +326,17 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), tx, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, tx, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -351,16 +351,16 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
|||
unparsed := c.Req.Form.Get("body")
|
||||
editReason := c.Req.Form.Get("editreason")
|
||||
if title != "" && post.Thread.FirstID != post.Post.ID {
|
||||
return RejectRequest(c, "You can only edit the title by editing the first post.")
|
||||
return c.RejectRequest("You can only edit the title by editing the first post.")
|
||||
}
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "You must provide a post body.")
|
||||
return c.RejectRequest("You must provide a post body.")
|
||||
}
|
||||
|
||||
hmndata.CreatePostVersion(c.Context(), tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
hmndata.CreatePostVersion(c, tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
|
||||
if title != "" {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
_, err := tx.Exec(c,
|
||||
`
|
||||
UPDATE thread SET title = $1 WHERE id = $2
|
||||
`,
|
||||
|
@ -372,7 +372,7 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post"))
|
||||
}
|
||||
|
@ -387,7 +387,7 @@ func BlogPostReply(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -421,11 +421,11 @@ func BlogPostReplySubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
err = c.Req.ParseForm()
|
||||
if err != nil {
|
||||
|
@ -433,12 +433,12 @@ func BlogPostReplySubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "Your reply cannot be empty.")
|
||||
return c.RejectRequest("Your reply cannot be empty.")
|
||||
}
|
||||
|
||||
newPostId, _ := hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)
|
||||
newPostId, _ := hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post"))
|
||||
}
|
||||
|
@ -453,11 +453,11 @@ func BlogPostDelete(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -503,19 +503,19 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
threadDeleted := hmndata.DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID)
|
||||
threadDeleted := hmndata.DeletePost(c, tx, cd.ThreadID, cd.PostID)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
|
||||
}
|
||||
|
@ -523,7 +523,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
if threadDeleted {
|
||||
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||
} else {
|
||||
thread, err := hmndata.FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, hmndata.ThreadsQuery{
|
||||
thread, err := hmndata.FetchThread(c, c.Conn, c.CurrentUser, cd.ThreadID, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
})
|
||||
|
@ -560,7 +560,7 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
res.ThreadID = threadId
|
||||
|
||||
c.Perf.StartBlock("SQL", "Verify that the thread exists")
|
||||
threadExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
|
||||
threadExists, err := db.QueryOneScalar[bool](c, c.Conn,
|
||||
`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM thread
|
||||
|
@ -588,7 +588,7 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
res.PostID = postId
|
||||
|
||||
c.Perf.StartBlock("SQL", "Verify that the post exists")
|
||||
postExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
|
||||
postExists, err := db.QueryOneScalar[bool](c, c.Conn,
|
||||
`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM post
|
||||
|
|
|
@ -0,0 +1,138 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"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/templates"
|
||||
)
|
||||
|
||||
func loadCommonData(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
||||
{
|
||||
// get user
|
||||
{
|
||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||
if err == nil {
|
||||
user, session, err := getCurrentUserAndSession(c, sessionCookie.Value)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
|
||||
}
|
||||
|
||||
c.CurrentUser = user
|
||||
c.CurrentSession = session
|
||||
}
|
||||
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
||||
}
|
||||
|
||||
// get current official project (HMN or otherwise, by subdomain)
|
||||
{
|
||||
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
||||
slug := strings.TrimRight(hostPrefix, ".")
|
||||
var owners []*models.User
|
||||
|
||||
if len(slug) > 0 {
|
||||
dbProject, err := hmndata.FetchProjectBySlug(c, c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err == nil {
|
||||
c.CurrentProject = &dbProject.Project
|
||||
c.CurrentProjectLogoUrl = templates.ProjectLogoUrl(&dbProject.Project, dbProject.LogoLightAsset, dbProject.LogoDarkAsset, c.Theme)
|
||||
owners = dbProject.Owners
|
||||
} else {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
// do nothing, this is fine
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.CurrentProject == nil {
|
||||
dbProject, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, models.HMNProjectID, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to fetch HMN project"))
|
||||
}
|
||||
c.CurrentProject = &dbProject.Project
|
||||
c.CurrentProjectLogoUrl = templates.ProjectLogoUrl(&dbProject.Project, dbProject.LogoLightAsset, dbProject.LogoDarkAsset, c.Theme)
|
||||
}
|
||||
|
||||
if c.CurrentProject == nil {
|
||||
panic("failed to load project data")
|
||||
}
|
||||
|
||||
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners)
|
||||
|
||||
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
|
||||
}
|
||||
|
||||
c.Theme = "light"
|
||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||
c.Theme = "dark"
|
||||
}
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Given a session id, fetches user data from the database. Will return nil if
|
||||
// the user cannot be found, and will only return an error if it's serious.
|
||||
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {
|
||||
session, err := auth.GetSession(c, c.Conn, sessionId)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrNoSession) {
|
||||
return nil, nil, nil
|
||||
} else {
|
||||
return nil, nil, oops.New(err, "failed to get current session")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := hmndata.FetchUserByUsername(c, c.Conn, nil, session.Username, hmndata.UsersQuery{
|
||||
AnyStatus: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
||||
return nil, nil, nil // user was deleted or something
|
||||
} else {
|
||||
return nil, nil, oops.New(err, "failed to get user for session")
|
||||
}
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func addCORSHeaders(c *RequestContext, res *ResponseData) {
|
||||
parsed, err := url.Parse(config.Config.BaseUrl)
|
||||
if err != nil {
|
||||
c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
|
||||
return
|
||||
}
|
||||
origin := ""
|
||||
origins, found := c.Req.Header["Origin"]
|
||||
if found {
|
||||
origin = origins[0]
|
||||
}
|
||||
if strings.HasSuffix(origin, parsed.Host) {
|
||||
res.Header().Add("Access-Control-Allow-Origin", origin)
|
||||
res.Header().Add("Access-Control-Allow-Credentials", "true")
|
||||
res.Header().Add("Vary", "Origin")
|
||||
}
|
||||
}
|
|
@ -35,31 +35,31 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
|||
// 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.")
|
||||
return c.RejectRequest("Failed to authenticate with Discord.")
|
||||
}
|
||||
}
|
||||
|
||||
// Do the actual token exchange
|
||||
code := query.Get("code")
|
||||
res, err := discord.ExchangeOAuthCode(c.Context(), code, hmnurl.BuildDiscordOAuthCallback())
|
||||
res, err := discord.ExchangeOAuthCode(c, code, hmnurl.BuildDiscordOAuthCallback())
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to exchange Discord authorization code"))
|
||||
}
|
||||
expiry := time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
|
||||
|
||||
user, err := discord.GetCurrentUserAsOAuth(c.Context(), res.AccessToken)
|
||||
user, err := discord.GetCurrentUserAsOAuth(c, res.AccessToken)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch Discord user info"))
|
||||
}
|
||||
|
||||
// Add the role on Discord
|
||||
err = discord.AddGuildMemberRole(c.Context(), user.ID, config.Config.Discord.MemberRoleID)
|
||||
err = discord.AddGuildMemberRole(c, user.ID, config.Config.Discord.MemberRoleID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to add member role"))
|
||||
}
|
||||
|
||||
// Add the user to our database
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
INSERT INTO discord_user (username, discriminator, access_token, refresh_token, avatar, locale, userid, expiry, hmn_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
|
@ -79,7 +79,7 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if c.CurrentUser.Status == models.UserStatusConfirmed {
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -98,13 +98,13 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func DiscordUnlink(c *RequestContext) ResponseData {
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
discordUser, err := db.QueryOne[models.DiscordUser](c.Context(), tx,
|
||||
discordUser, err := db.QueryOne[models.DiscordUser](c, tx,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_user
|
||||
|
@ -120,7 +120,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM discord_user
|
||||
WHERE id = $1
|
||||
|
@ -131,12 +131,12 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete Discord user"))
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit Discord user delete"))
|
||||
}
|
||||
|
||||
err = discord.RemoveGuildMemberRole(c.Context(), discordUser.UserID, config.Config.Discord.MemberRoleID)
|
||||
err = discord.RemoveGuildMemberRole(c, discordUser.UserID, config.Config.Discord.MemberRoleID)
|
||||
if err != nil {
|
||||
c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink")
|
||||
}
|
||||
|
@ -145,7 +145,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
||||
duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
|
||||
duser, err := db.QueryOne[models.DiscordUser](c, c.Conn,
|
||||
`SELECT $columns FROM discord_user WHERE hmn_user_id = $1`,
|
||||
c.CurrentUser.ID,
|
||||
)
|
||||
|
@ -157,7 +157,7 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user"))
|
||||
}
|
||||
|
||||
msgIDs, err := db.QueryScalar[string](c.Context(), c.Conn,
|
||||
msgIDs, err := db.QueryScalar[string](c, c.Conn,
|
||||
`
|
||||
SELECT msg.id
|
||||
FROM
|
||||
|
@ -174,12 +174,12 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
for _, msgID := range msgIDs {
|
||||
interned, err := discord.FetchInternedMessage(c.Context(), c.Conn, msgID)
|
||||
interned, err := discord.FetchInternedMessage(c, c.Conn, msgID)
|
||||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
} else if err == nil {
|
||||
// NOTE(asaf): Creating snippet even if the checkbox is off because the user asked us to.
|
||||
err = discord.HandleSnippetForInternedMessage(c.Context(), c.Conn, interned, true)
|
||||
err = discord.HandleSnippetForInternedMessage(c, c.Conn, interned, true)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,31 @@
|
|||
package website
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
)
|
||||
|
||||
func FourOhFour(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
res.StatusCode = http.StatusNotFound
|
||||
|
||||
if c.Req.Header["Accept"] != nil && strings.Contains(c.Req.Header["Accept"][0], "text/html") {
|
||||
templateData := struct {
|
||||
templates.BaseData
|
||||
Wanted string
|
||||
}{
|
||||
BaseData: getBaseData(c, "Page not found", nil),
|
||||
Wanted: c.FullUrl(),
|
||||
}
|
||||
res.MustWriteTemplate("404.html", templateData, c.Perf)
|
||||
} else {
|
||||
res.Write([]byte("Not Found"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// A SafeError can be used to wrap another error and explicitly provide
|
||||
// an error message that is safe to show to a user. This allows the original
|
||||
|
|
|
@ -33,7 +33,7 @@ var feedThreadTypes = []models.ThreadType{
|
|||
}
|
||||
|
||||
func Feed(c *RequestContext) ResponseData {
|
||||
numPosts, err := hmndata.CountPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
numPosts, err := hmndata.CountPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ThreadTypes: feedThreadTypes,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -156,7 +156,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
if hasAll {
|
||||
itemsPerFeed = 100000
|
||||
}
|
||||
projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, nil, hmndata.ProjectsQuery{
|
||||
projectsAndStuff, err := hmndata.FetchProjects(c, c.Conn, nil, hmndata.ProjectsQuery{
|
||||
Limit: itemsPerFeed,
|
||||
Types: hmndata.OfficialProjects,
|
||||
OrderBy: "date_approved DESC",
|
||||
|
@ -188,7 +188,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
||||
feedData.FeedUrl = hmnurl.BuildShowcase()
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
Limit: itemsPerFeed,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -215,7 +215,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func fetchAllPosts(c *RequestContext, offset int, limit int) ([]templates.PostListItem, error) {
|
||||
postsAndStuff, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
postsAndStuff, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ThreadTypes: feedThreadTypes,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
|
@ -225,7 +225,7 @@ func fetchAllPosts(c *RequestContext, offset int, limit int) ([]templates.PostLi
|
|||
return nil, err
|
||||
}
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
|
|
@ -58,6 +58,7 @@ var fishbowls = [...]fishbowlInfo{
|
|||
Title: "Approaches to parallel programming",
|
||||
Description: `A discussion of many aspects of parallelism and concurrency in programming, and the pros and cons of different programming methodologies.`,
|
||||
Month: time.November, Year: 2020,
|
||||
ContentsPath: "parallel-programming/parallel-programming.html",
|
||||
},
|
||||
{
|
||||
Slug: "skimming",
|
||||
|
@ -205,7 +206,7 @@ func Fishbowl(c *RequestContext) ResponseData {
|
|||
func FishbowlFiles(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
fishbowlHTTPFS.ServeHTTP(&res, c.Req)
|
||||
AddCORSHeaders(c, &res)
|
||||
addCORSHeaders(c, &res)
|
||||
return res
|
||||
}
|
||||
|
||||
|
@ -223,7 +224,7 @@ func linkifyDiscordContent(c *RequestContext, dbConn db.ConnOrTx, content string
|
|||
discordUserIds = append(discordUserIds, id)
|
||||
}
|
||||
|
||||
hmnUsers, err := hmndata.FetchUsers(c.Context(), dbConn, c.CurrentUser, hmndata.UsersQuery{
|
||||
hmnUsers, err := hmndata.FetchUsers(c, dbConn, c.CurrentUser, hmndata.UsersQuery{
|
||||
DiscordUserIDs: discordUserIds,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
@ -91,7 +91,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
|
||||
currentSubforumSlugs := cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID)
|
||||
|
||||
numThreads, err := hmndata.CountThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
numThreads, err := hmndata.CountThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
SubforumIDs: []int{cd.SubforumID},
|
||||
|
@ -107,7 +107,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
}
|
||||
howManyThreadsToSkip := (page - 1) * threadsPerPage
|
||||
|
||||
mainThreads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
mainThreads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
SubforumIDs: []int{cd.SubforumID},
|
||||
|
@ -141,7 +141,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
subforumNodes := cd.SubforumTree[cd.SubforumID].Children
|
||||
|
||||
for _, sfNode := range subforumNodes {
|
||||
numThreads, err := hmndata.CountThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
numThreads, err := hmndata.CountThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
SubforumIDs: []int{sfNode.ID},
|
||||
|
@ -150,7 +150,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
panic(oops.New(err, "failed to get count of threads"))
|
||||
}
|
||||
|
||||
subforumThreads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
subforumThreads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
SubforumIDs: []int{sfNode.ID},
|
||||
|
@ -203,7 +203,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
|
||||
func ForumMarkRead(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -212,16 +212,16 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
sfIds := []int{sfId}
|
||||
if sfId == 0 {
|
||||
// Mark literally everything as read
|
||||
_, err := tx.Exec(c.Context(),
|
||||
_, err := tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET marked_all_read_at = NOW()
|
||||
|
@ -234,7 +234,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// Delete thread unread info
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM thread_last_read_info
|
||||
WHERE user_id = $1;
|
||||
|
@ -246,7 +246,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// Delete subforum unread info
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM subforum_last_read_info
|
||||
WHERE user_id = $1;
|
||||
|
@ -258,7 +258,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
}
|
||||
} else {
|
||||
c.Perf.StartBlock("SQL", "Update SLRIs")
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
INSERT INTO subforum_last_read_info (subforum_id, user_id, lastread)
|
||||
SELECT id, $2, $3
|
||||
|
@ -277,7 +277,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Delete TLRIs")
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
DELETE FROM thread_last_read_info
|
||||
WHERE
|
||||
|
@ -298,7 +298,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to commit SLRI/TLRI updates"))
|
||||
}
|
||||
|
@ -332,7 +332,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
threads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
threads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadIDs: []int{cd.ThreadID},
|
||||
})
|
||||
|
@ -351,7 +351,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
return c.Redirect(correctThreadUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
numPosts, err := hmndata.CountPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
numPosts, err := hmndata.CountPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
ThreadIDs: []int{cd.ThreadID},
|
||||
|
@ -374,7 +374,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
PreviousUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
|
||||
}
|
||||
|
||||
postsAndStuff, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
postsAndStuff, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadIDs: []int{thread.ID},
|
||||
Limit: threadPostsPerPage,
|
||||
|
@ -396,7 +396,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
post.ReplyPost = &reply
|
||||
}
|
||||
|
||||
addAuthorCountsToPost(c.Context(), c.Conn, &post)
|
||||
addAuthorCountsToPost(c, c.Conn, &post)
|
||||
|
||||
posts = append(posts, post)
|
||||
}
|
||||
|
@ -404,7 +404,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
// Update thread last read info
|
||||
if c.CurrentUser != nil {
|
||||
c.Perf.StartBlock("SQL", "Update TLRI")
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
INSERT INTO thread_last_read_info (thread_id, user_id, lastread)
|
||||
VALUES ($1, $2, $3)
|
||||
|
@ -445,7 +445,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
ThreadIDs: []int{cd.ThreadID},
|
||||
|
@ -495,11 +495,11 @@ func ForumNewThread(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
cd, ok := getCommonForumData(c)
|
||||
if !ok {
|
||||
|
@ -517,15 +517,15 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
sticky = true
|
||||
}
|
||||
if title == "" {
|
||||
return RejectRequest(c, "You must provide a title for your post.")
|
||||
return c.RejectRequest("You must provide a title for your post.")
|
||||
}
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "You must provide a body for your post.")
|
||||
return c.RejectRequest("You must provide a body for your post.")
|
||||
}
|
||||
|
||||
// Create thread
|
||||
var threadId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
err = tx.QueryRow(c,
|
||||
`
|
||||
INSERT INTO thread (title, sticky, type, project_id, subforum_id, first_id, last_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
|
@ -544,9 +544,9 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// Create everything else
|
||||
hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeForumPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
|
||||
hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, threadId, models.ThreadTypeForumPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
|
||||
}
|
||||
|
@ -561,7 +561,7 @@ func ForumPostReply(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
})
|
||||
|
@ -600,11 +600,11 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
err = c.Req.ParseForm()
|
||||
if err != nil {
|
||||
|
@ -612,10 +612,10 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "Your reply cannot be empty.")
|
||||
return c.RejectRequest("Your reply cannot be empty.")
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
})
|
||||
|
@ -629,9 +629,9 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
|||
replyPostId = &post.Post.ID
|
||||
}
|
||||
|
||||
newPostId, _ := hmndata.CreateNewPost(c.Context(), tx, c.CurrentProject.ID, post.Thread.ID, models.ThreadTypeForumPost, c.CurrentUser.ID, replyPostId, unparsed, c.Req.Host)
|
||||
newPostId, _ := hmndata.CreateNewPost(c, tx, c.CurrentProject.ID, post.Thread.ID, models.ThreadTypeForumPost, c.CurrentUser.ID, replyPostId, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
|
||||
}
|
||||
|
@ -646,11 +646,11 @@ func ForumPostEdit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
})
|
||||
|
@ -688,17 +688,17 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), tx, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, tx, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
})
|
||||
|
@ -713,16 +713,16 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
unparsed := c.Req.Form.Get("body")
|
||||
editReason := c.Req.Form.Get("editreason")
|
||||
if title != "" && post.Thread.FirstID != post.Post.ID {
|
||||
return RejectRequest(c, "You can only edit the title by editing the first post.")
|
||||
return c.RejectRequest("You can only edit the title by editing the first post.")
|
||||
}
|
||||
if unparsed == "" {
|
||||
return RejectRequest(c, "You must provide a body for your post.")
|
||||
return c.RejectRequest("You must provide a body for your post.")
|
||||
}
|
||||
|
||||
hmndata.CreatePostVersion(c.Context(), tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
hmndata.CreatePostVersion(c, tx, post.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||
|
||||
if title != "" {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
_, err := tx.Exec(c,
|
||||
`
|
||||
UPDATE thread SET title = $1 WHERE id = $2
|
||||
`,
|
||||
|
@ -734,7 +734,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
|
||||
}
|
||||
|
@ -749,11 +749,11 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
post, err := hmndata.FetchThreadPost(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
post, err := hmndata.FetchThreadPost(c, c.Conn, c.CurrentUser, cd.ThreadID, cd.PostID, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost},
|
||||
})
|
||||
|
@ -798,19 +798,19 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
if !hmndata.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
if !hmndata.UserCanEditPost(c, c.Conn, *c.CurrentUser, cd.PostID) {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
threadDeleted := hmndata.DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID)
|
||||
threadDeleted := hmndata.DeletePost(c, tx, cd.ThreadID, cd.PostID)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
|
||||
}
|
||||
|
@ -831,7 +831,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData {
|
|||
panic(err)
|
||||
}
|
||||
|
||||
thread, err := hmndata.FetchThread(c.Context(), c.Conn, c.CurrentUser, threadId, hmndata.ThreadsQuery{
|
||||
thread, err := hmndata.FetchThread(c, c.Conn, c.CurrentUser, threadId, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
// This is the rare query where we want all thread types!
|
||||
})
|
||||
|
@ -842,7 +842,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -874,7 +874,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
|
|||
defer c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@ func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string,
|
|||
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)
|
||||
imageFile, err := db.QueryOne[models.ImageFile](c.Context(), dbConn,
|
||||
imageFile, err := db.QueryOne[models.ImageFile](c, dbConn,
|
||||
`
|
||||
INSERT INTO image_file (file, size, sha1sum, protected, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
|
|
|
@ -51,7 +51,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
tagId := -1
|
||||
jamTag, err := hmndata.FetchTag(c.Context(), c.Conn, hmndata.TagQuery{
|
||||
jamTag, err := hmndata.FetchTag(c, c.Conn, hmndata.TagQuery{
|
||||
Text: []string{"wheeljam"},
|
||||
})
|
||||
if err == nil {
|
||||
|
@ -60,7 +60,7 @@ func JamIndex2021(c *RequestContext) ResponseData {
|
|||
c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result")
|
||||
}
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
Tags: []int{tagId},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
@ -34,13 +34,13 @@ type LandingTemplateData struct {
|
|||
|
||||
func Index(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
var timelineItems []templates.TimelineItem
|
||||
|
||||
numPosts, err := hmndata.CountPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
numPosts, err := hmndata.CountPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ThreadTypes: feedThreadTypes,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -65,7 +65,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// This is essentially an alternate for feed page 1.
|
||||
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ThreadTypes: feedThreadTypes,
|
||||
Limit: feedPostsPerPage,
|
||||
SortDescending: true,
|
||||
|
@ -84,7 +84,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Get news")
|
||||
newsThreads, err := hmndata.FetchThreads(c.Context(), c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
newsThreads, err := hmndata.FetchThreads(c, c.Conn, c.CurrentUser, hmndata.ThreadsQuery{
|
||||
ProjectIDs: []int{models.HMNProjectID},
|
||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||
Limit: 1,
|
||||
|
@ -106,7 +106,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
Limit: 40,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
)
|
||||
|
||||
func panicCatcherMiddleware(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
maybeError, ok := recovered.(*error)
|
||||
var err error
|
||||
if ok {
|
||||
err = *maybeError
|
||||
} else {
|
||||
err = oops.New(nil, fmt.Sprintf("Recovered from panic with value: %v", recovered))
|
||||
}
|
||||
res = c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func trackRequestPerf(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
|
||||
defer func() {
|
||||
c.Perf.EndRequest()
|
||||
log := logging.Info()
|
||||
blockStack := make([]time.Time, 0)
|
||||
for i, block := range c.Perf.Blocks {
|
||||
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
|
||||
blockStack = blockStack[:len(blockStack)-1]
|
||||
}
|
||||
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
|
||||
blockStack = append(blockStack, block.End)
|
||||
}
|
||||
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
|
||||
// perfCollector.SubmitRun(c.Perf) // TODO(asaf): Implement a use for this
|
||||
}()
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuth(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
if c.CurrentUser == nil {
|
||||
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func adminsOnly(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfMiddleware(h Handler) Handler {
|
||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||
return func(c *RequestContext) ResponseData {
|
||||
c.Req.ParseMultipartForm(100 * 1024 * 1024)
|
||||
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
||||
if csrfToken != c.CurrentSession.CSRFToken {
|
||||
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
||||
|
||||
res := c.Redirect("/", http.StatusSeeOther)
|
||||
logoutUser(c, &res)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func securityTimerMiddleware(duration time.Duration, h Handler) Handler {
|
||||
// NOTE(asaf): Will make sure that the request takes at least `duration` to finish. Adds a 10% random duration.
|
||||
return func(c *RequestContext) ResponseData {
|
||||
additionalDuration := time.Duration(rand.Int63n(utils.Int64Max(1, int64(duration)/10)))
|
||||
timer := time.NewTimer(duration + additionalDuration)
|
||||
res := h(c)
|
||||
select {
|
||||
case <-c.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
func logContextErrors(c *RequestContext, errs ...error) {
|
||||
for _, err := range errs {
|
||||
c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
|
||||
}
|
||||
}
|
||||
|
||||
func logContextErrorsMiddleware(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
res := h(c)
|
||||
logContextErrors(c, res.Errors...)
|
||||
return res
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
)
|
||||
|
||||
const NoticesCookieName = "hmn_notices"
|
||||
|
||||
func getNoticesFromCookie(c *RequestContext) []templates.Notice {
|
||||
cookie, err := c.Req.Cookie(NoticesCookieName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
c.Logger.Warn().Err(err).Msg("failed to get notices cookie")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return deserializeNoticesFromCookie(cookie.Value)
|
||||
}
|
||||
|
||||
func storeNoticesInCookie(c *RequestContext, res *ResponseData) {
|
||||
serialized := serializeNoticesForCookie(c, res.FutureNotices)
|
||||
if serialized != "" {
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Value: serialized,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
Expires: time.Now().Add(time.Minute * 5),
|
||||
Secure: config.Config.Auth.CookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
} else if !(res.StatusCode >= 300 && res.StatusCode < 400) {
|
||||
// NOTE(asaf): Don't clear on redirect
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
MaxAge: -1,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func serializeNoticesForCookie(c *RequestContext, notices []templates.Notice) string {
|
||||
var builder strings.Builder
|
||||
maxSize := 1024 // NOTE(asaf): Make sure we don't use too much space for notices.
|
||||
size := 0
|
||||
for i, notice := range notices {
|
||||
sizeIncrease := len(notice.Class) + len(string(notice.Content)) + 1
|
||||
if i != 0 {
|
||||
sizeIncrease += 1
|
||||
}
|
||||
if size+sizeIncrease > maxSize {
|
||||
c.Logger.Warn().Interface("Notices", notices).Msg("Notices too big for cookie")
|
||||
break
|
||||
}
|
||||
|
||||
if i != 0 {
|
||||
builder.WriteString("\t")
|
||||
}
|
||||
builder.WriteString(notice.Class)
|
||||
builder.WriteString("|")
|
||||
builder.WriteString(string(notice.Content))
|
||||
|
||||
size += sizeIncrease
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func deserializeNoticesFromCookie(cookieVal string) []templates.Notice {
|
||||
var result []templates.Notice
|
||||
notices := strings.Split(cookieVal, "\t")
|
||||
for _, notice := range notices {
|
||||
parts := strings.SplitN(notice, "|", 2)
|
||||
if len(parts) == 2 {
|
||||
result = append(result, templates.Notice{
|
||||
Class: parts[0],
|
||||
Content: template.HTML(parts[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func storeNoticesInCookieMiddleware(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
res := h(c)
|
||||
storeNoticesInCookie(c, &res)
|
||||
return res
|
||||
}
|
||||
}
|
|
@ -126,29 +126,29 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
title := c.Req.Form.Get("title")
|
||||
if len(strings.TrimSpace(title)) == 0 {
|
||||
return RejectRequest(c, "Podcast title is empty")
|
||||
return c.RejectRequest("Podcast title is empty")
|
||||
}
|
||||
description := c.Req.Form.Get("description")
|
||||
if len(strings.TrimSpace(description)) == 0 {
|
||||
return RejectRequest(c, "Podcast description is empty")
|
||||
return c.RejectRequest("Podcast description is empty")
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Updating podcast")
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
imageSaveResult := SaveImageFile(c, tx, "podcast_image", maxFileSize, fmt.Sprintf("podcast/%s/logo%d", c.CurrentProject.Slug, time.Now().UTC().Unix()))
|
||||
if imageSaveResult.ValidationError != "" {
|
||||
return RejectRequest(c, imageSaveResult.ValidationError)
|
||||
return c.RejectRequest(imageSaveResult.ValidationError)
|
||||
} else if imageSaveResult.FatalError != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(imageSaveResult.FatalError, "Failed to save podcast image"))
|
||||
}
|
||||
|
||||
if imageSaveResult.ImageFile != nil {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE podcast
|
||||
SET
|
||||
|
@ -166,7 +166,7 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast"))
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE podcast
|
||||
SET
|
||||
|
@ -179,7 +179,7 @@ func PodcastEditSubmit(c *RequestContext) ResponseData {
|
|||
podcastResult.Podcast.ID,
|
||||
)
|
||||
}
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction"))
|
||||
|
@ -357,16 +357,16 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
c.Req.ParseForm()
|
||||
title := c.Req.Form.Get("title")
|
||||
if len(strings.TrimSpace(title)) == 0 {
|
||||
return RejectRequest(c, "Episode title is empty")
|
||||
return c.RejectRequest("Episode title is empty")
|
||||
}
|
||||
description := c.Req.Form.Get("description")
|
||||
if len(strings.TrimSpace(description)) == 0 {
|
||||
return RejectRequest(c, "Episode description is empty")
|
||||
return c.RejectRequest("Episode description is empty")
|
||||
}
|
||||
episodeNumberStr := c.Req.Form.Get("episode_number")
|
||||
episodeNumber, err := strconv.Atoi(episodeNumberStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "Episode number can't be parsed")
|
||||
return c.RejectRequest("Episode number can't be parsed")
|
||||
}
|
||||
episodeFile := c.Req.Form.Get("episode_file")
|
||||
found = false
|
||||
|
@ -378,7 +378,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if !found {
|
||||
return RejectRequest(c, "Requested episode file not found")
|
||||
return c.RejectRequest("Requested episode file not found")
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("MP3", "Parsing mp3 file for duration")
|
||||
|
@ -417,7 +417,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
if isEdit {
|
||||
guidStr = podcastResult.Episodes[0].GUID.String()
|
||||
c.Perf.StartBlock("SQL", "Updating podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
_, err := c.Conn.Exec(c,
|
||||
`
|
||||
UPDATE podcast_episode
|
||||
SET
|
||||
|
@ -446,7 +446,7 @@ func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
|||
guid := uuid.New()
|
||||
guidStr = guid.String()
|
||||
c.Perf.StartBlock("SQL", "Creating new podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
_, err := c.Conn.Exec(c,
|
||||
`
|
||||
INSERT INTO podcast_episode
|
||||
(guid, title, description, description_rendered, audio_filename, duration, pub_date, episode_number, podcast_id)
|
||||
|
@ -532,7 +532,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
Podcast models.Podcast `db:"podcast"`
|
||||
ImageFilename string `db:"imagefile.file"`
|
||||
}
|
||||
podcastQueryResult, err := db.QueryOne[podcastQuery](c.Context(), c.Conn,
|
||||
podcastQueryResult, err := db.QueryOne[podcastQuery](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -558,7 +558,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
if fetchEpisodes {
|
||||
if episodeGUID == "" {
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episodes")
|
||||
episodes, err := db.Query[models.PodcastEpisode](c.Context(), c.Conn,
|
||||
episodes, err := db.Query[models.PodcastEpisode](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM podcast_episode AS episode
|
||||
|
@ -578,7 +578,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
|
|||
return result, err
|
||||
}
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episode")
|
||||
episode, err := db.QueryOne[models.PodcastEpisode](c.Context(), c.Conn,
|
||||
episode, err := db.QueryOne[models.PodcastEpisode](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM podcast_episode AS episode
|
||||
|
|
|
@ -26,11 +26,52 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v4"
|
||||
"github.com/teacat/noire"
|
||||
)
|
||||
|
||||
const maxPersonalProjects = 5
|
||||
const maxProjectOwners = 5
|
||||
|
||||
func ProjectCSS(c *RequestContext) ResponseData {
|
||||
color := c.URL().Query().Get("color")
|
||||
if color == "" {
|
||||
return c.ErrorResponse(http.StatusBadRequest, NewSafeError(nil, "You must provide a 'color' parameter.\n"))
|
||||
}
|
||||
|
||||
baseData := getBaseData(c, "", nil)
|
||||
|
||||
bgColor := noire.NewHex(color)
|
||||
h, s, l := bgColor.HSL()
|
||||
if baseData.Theme == "dark" {
|
||||
l = 15
|
||||
} else {
|
||||
l = 95
|
||||
}
|
||||
if s > 20 {
|
||||
s = 20
|
||||
}
|
||||
bgColor = noire.NewHSL(h, s, l)
|
||||
|
||||
templateData := struct {
|
||||
templates.BaseData
|
||||
Color string
|
||||
PostBgColor string
|
||||
}{
|
||||
BaseData: baseData,
|
||||
Color: color,
|
||||
PostBgColor: bgColor.HTML(),
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.Header().Add("Content-Type", "text/css")
|
||||
err := res.WriteTemplate("project.css", templateData, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to generate project CSS"))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
type ProjectTemplateData struct {
|
||||
templates.BaseData
|
||||
|
||||
|
@ -48,7 +89,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
const maxCarouselProjects = 10
|
||||
const maxPersonalProjects = 10
|
||||
|
||||
officialProjects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
officialProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
Types: hmndata.OfficialProjects,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -123,7 +164,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
// Fetch and highlight a random selection of personal projects
|
||||
var personalProjects []templates.Project
|
||||
{
|
||||
projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
projects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
Types: hmndata.PersonalProjects,
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -181,13 +222,13 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
// There are no further permission checks to do, because permissions are
|
||||
// checked whatever way we fetch the project.
|
||||
|
||||
owners, err := hmndata.FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||
owners, err := hmndata.FetchProjectOwners(c, c.Conn, c.CurrentProject.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
||||
screenshotFilenames, err := db.QueryScalar[string](c.Context(), c.Conn,
|
||||
screenshotFilenames, err := db.QueryScalar[string](c, c.Conn,
|
||||
`
|
||||
SELECT screenshot.file
|
||||
FROM
|
||||
|
@ -204,7 +245,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project links")
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
projectLinks, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -221,12 +262,12 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project timeline")
|
||||
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
Limit: maxRecentActivity,
|
||||
SortDescending: true,
|
||||
|
@ -241,7 +282,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
Value: c.CurrentProject.Blurb,
|
||||
})
|
||||
|
||||
p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID, hmndata.ProjectsQuery{
|
||||
p, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, c.CurrentProject.ID, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
|
@ -317,7 +358,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
tagId = *c.CurrentProject.TagID
|
||||
}
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
Tags: []int{tagId},
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -364,7 +405,7 @@ type ProjectEditData struct {
|
|||
}
|
||||
|
||||
func ProjectNew(c *RequestContext) ResponseData {
|
||||
numProjects, err := hmndata.CountProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
numProjects, err := hmndata.CountProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{c.CurrentUser.ID},
|
||||
Types: hmndata.PersonalProjects,
|
||||
})
|
||||
|
@ -372,7 +413,7 @@ func ProjectNew(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check number of personal projects"))
|
||||
}
|
||||
if numProjects >= maxPersonalProjects {
|
||||
return RejectRequest(c, fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
|
||||
return c.RejectRequest(fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
|
||||
}
|
||||
|
||||
var project templates.ProjectSettings
|
||||
|
@ -397,16 +438,16 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
|
||||
}
|
||||
if len(formResult.RejectionReason) != 0 {
|
||||
return RejectRequest(c, formResult.RejectionReason)
|
||||
return c.RejectRequest(formResult.RejectionReason)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
numProjects, err := hmndata.CountProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
numProjects, err := hmndata.CountProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{c.CurrentUser.ID},
|
||||
Types: hmndata.PersonalProjects,
|
||||
})
|
||||
|
@ -414,11 +455,11 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check number of personal projects"))
|
||||
}
|
||||
if numProjects >= maxPersonalProjects {
|
||||
return RejectRequest(c, fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
|
||||
return c.RejectRequest(fmt.Sprintf("You have already reached the maximum of %d personal projects.", maxPersonalProjects))
|
||||
}
|
||||
|
||||
var projectId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
err = tx.QueryRow(c,
|
||||
`
|
||||
INSERT INTO project
|
||||
(name, blurb, description, descparsed, lifecycle, date_created, all_last_updated)
|
||||
|
@ -439,12 +480,12 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
|||
|
||||
formResult.Payload.ProjectID = projectId
|
||||
|
||||
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||
err = updateProject(c, tx, c.CurrentUser, &formResult.Payload)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
tx.Commit(c.Context())
|
||||
tx.Commit(c)
|
||||
|
||||
urlContext := &hmnurl.UrlContext{
|
||||
PersonalProject: true,
|
||||
|
@ -461,7 +502,7 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
p, err := hmndata.FetchProject(
|
||||
c.Context(), c.Conn,
|
||||
c, c.Conn,
|
||||
c.CurrentUser, c.CurrentProject.ID,
|
||||
hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
|
@ -473,7 +514,7 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetching project links")
|
||||
projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
projectLinks, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -524,23 +565,23 @@ func ProjectEditSubmit(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, formResult.Error)
|
||||
}
|
||||
if len(formResult.RejectionReason) != 0 {
|
||||
return RejectRequest(c, formResult.RejectionReason)
|
||||
return c.RejectRequest(formResult.RejectionReason)
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
formResult.Payload.ProjectID = c.CurrentProject.ID
|
||||
|
||||
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||
err = updateProject(c, tx, c.CurrentUser, &formResult.Payload)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
tx.Commit(c.Context())
|
||||
tx.Commit(c)
|
||||
|
||||
urlContext := &hmnurl.UrlContext{
|
||||
PersonalProject: formResult.Payload.Personal,
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
|
@ -43,15 +44,22 @@ func (r *Route) String() string {
|
|||
}
|
||||
|
||||
type RouteBuilder struct {
|
||||
Router *Router
|
||||
Prefixes []*regexp.Regexp
|
||||
Middleware Middleware
|
||||
Router *Router
|
||||
Prefixes []*regexp.Regexp
|
||||
Middlewares []Middleware
|
||||
}
|
||||
|
||||
type Handler func(c *RequestContext) ResponseData
|
||||
|
||||
type Middleware func(h Handler) Handler
|
||||
|
||||
func applyMiddlewares(h Handler, ms []Middleware) Handler {
|
||||
result := h
|
||||
for i := len(ms) - 1; i >= 0; i-- {
|
||||
result = ms[i](result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
|
||||
// Ensure that this regex matches the start of the string
|
||||
regexStr := regex.String()
|
||||
|
@ -59,7 +67,7 @@ func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler
|
|||
panic("All routing regexes must begin with '^'")
|
||||
}
|
||||
|
||||
h = rb.Middleware(h)
|
||||
h = applyMiddlewares(h, rb.Middlewares)
|
||||
for _, method := range methods {
|
||||
rb.Router.Routes = append(rb.Router.Routes, Route{
|
||||
Method: method,
|
||||
|
@ -81,10 +89,19 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
|
|||
rb.Handle([]string{http.MethodPost}, regex, h)
|
||||
}
|
||||
|
||||
func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) {
|
||||
func (rb *RouteBuilder) WithMiddleware(ms ...Middleware) RouteBuilder {
|
||||
newRb := *rb
|
||||
newRb.Middlewares = append(rb.Middlewares, ms...)
|
||||
|
||||
return newRb
|
||||
}
|
||||
|
||||
func (rb *RouteBuilder) Group(regex *regexp.Regexp, ms ...Middleware) RouteBuilder {
|
||||
newRb := *rb
|
||||
newRb.Prefixes = append(newRb.Prefixes, regex)
|
||||
addRoutes(&newRb)
|
||||
newRb.Middlewares = append(rb.Middlewares, ms...)
|
||||
|
||||
return newRb
|
||||
}
|
||||
|
||||
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
|
@ -138,6 +155,8 @@ nextroute:
|
|||
Req: req,
|
||||
Res: rw,
|
||||
PathParams: params,
|
||||
|
||||
ctx: req.Context(),
|
||||
}
|
||||
c.PathParams = params
|
||||
|
||||
|
@ -174,13 +193,33 @@ type RequestContext struct {
|
|||
ctx context.Context
|
||||
}
|
||||
|
||||
func (c *RequestContext) Context() context.Context {
|
||||
if c.ctx == nil {
|
||||
c.ctx = c.Req.Context()
|
||||
}
|
||||
return c.ctx
|
||||
// Our RequestContext is a context.Context
|
||||
|
||||
var _ context.Context = &RequestContext{}
|
||||
|
||||
func (c *RequestContext) Deadline() (time.Time, bool) {
|
||||
return c.ctx.Deadline()
|
||||
}
|
||||
|
||||
func (c *RequestContext) Done() <-chan struct{} {
|
||||
return c.ctx.Done()
|
||||
}
|
||||
|
||||
func (c *RequestContext) Err() error {
|
||||
return c.ctx.Err()
|
||||
}
|
||||
|
||||
func (c *RequestContext) Value(key any) any {
|
||||
switch key {
|
||||
case perf.PerfContextKey:
|
||||
return c.Perf
|
||||
default:
|
||||
return c.ctx.Value(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Plus it does many other things specific to us
|
||||
|
||||
func (c *RequestContext) URL() *url.URL {
|
||||
return c.Req.URL
|
||||
}
|
||||
|
@ -325,7 +364,7 @@ func (c *RequestContext) Redirect(dest string, code int) ResponseData {
|
|||
func (c *RequestContext) ErrorResponse(status int, errs ...error) ResponseData {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
LogContextErrors(c, errs...)
|
||||
logContextErrors(c, errs...)
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
@ -338,6 +377,23 @@ func (c *RequestContext) ErrorResponse(status int, errs ...error) ResponseData {
|
|||
return res
|
||||
}
|
||||
|
||||
func (c *RequestContext) RejectRequest(reason string) ResponseData {
|
||||
type RejectData struct {
|
||||
templates.BaseData
|
||||
RejectReason string
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err := res.WriteTemplate("reject.html", RejectData{
|
||||
BaseData: getBaseData(c, "Rejected", nil),
|
||||
RejectReason: reason,
|
||||
}, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to render reject template"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type ResponseData struct {
|
||||
StatusCode int
|
||||
Body *bytes.Buffer
|
||||
|
|
|
@ -1,159 +1,46 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/email"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"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/perf"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v4/pgxpool"
|
||||
"github.com/teacat/noire"
|
||||
)
|
||||
|
||||
func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) http.Handler {
|
||||
func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
|
||||
router := &Router{}
|
||||
routes := RouteBuilder{
|
||||
Router: router,
|
||||
Middleware: func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
c.Conn = conn
|
||||
|
||||
logPerf := TrackRequestPerf(c)
|
||||
defer logPerf()
|
||||
|
||||
defer LogContextErrorsFromResponse(c, &res)
|
||||
defer MiddlewarePanicCatcher(c, &res)
|
||||
|
||||
return h(c)
|
||||
}
|
||||
Middlewares: []Middleware{
|
||||
setDBConn(conn),
|
||||
trackRequestPerf,
|
||||
logContextErrorsMiddleware,
|
||||
panicCatcherMiddleware,
|
||||
},
|
||||
}
|
||||
|
||||
anyProject := routes
|
||||
anyProject.Middleware = func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
c.Conn = conn
|
||||
|
||||
logPerf := TrackRequestPerf(c)
|
||||
defer logPerf()
|
||||
|
||||
defer LogContextErrorsFromResponse(c, &res)
|
||||
defer MiddlewarePanicCatcher(c, &res)
|
||||
|
||||
defer storeNoticesInCookie(c, &res)
|
||||
|
||||
ok, errRes := LoadCommonWebsiteData(c)
|
||||
if !ok {
|
||||
return errRes
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
hmnOnly := routes
|
||||
hmnOnly.Middleware = func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
c.Conn = conn
|
||||
|
||||
logPerf := TrackRequestPerf(c)
|
||||
defer logPerf()
|
||||
|
||||
defer LogContextErrorsFromResponse(c, &res)
|
||||
defer MiddlewarePanicCatcher(c, &res)
|
||||
|
||||
defer storeNoticesInCookie(c, &res)
|
||||
|
||||
ok, errRes := LoadCommonWebsiteData(c)
|
||||
if !ok {
|
||||
return errRes
|
||||
}
|
||||
|
||||
if !c.CurrentProject.IsHMN() {
|
||||
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
authMiddleware := func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
if c.CurrentUser == nil {
|
||||
return c.Redirect(hmnurl.BuildLoginPage(c.FullUrl()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
adminMiddleware := func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
if c.CurrentUser == nil || !c.CurrentUser.IsStaff {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
csrfMiddleware := func(h Handler) Handler {
|
||||
// CSRF mitigation actions per the OWASP cheat sheet:
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
||||
return func(c *RequestContext) ResponseData {
|
||||
c.Req.ParseMultipartForm(100 * 1024 * 1024)
|
||||
csrfToken := c.Req.Form.Get(auth.CSRFFieldName)
|
||||
if csrfToken != c.CurrentSession.CSRFToken {
|
||||
c.Logger.Warn().Str("userId", c.CurrentUser.Username).Msg("user failed CSRF validation - potential attack?")
|
||||
|
||||
res := c.Redirect("/", http.StatusSeeOther)
|
||||
logoutUser(c, &res)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
securityTimerMiddleware := func(duration time.Duration, h Handler) Handler {
|
||||
// NOTE(asaf): Will make sure that the request takes at least `delayMs` to finish. Adds a 10% random duration.
|
||||
return func(c *RequestContext) ResponseData {
|
||||
additionalDuration := time.Duration(rand.Int63n(utils.Int64Max(1, int64(duration)/10)))
|
||||
timer := time.NewTimer(duration + additionalDuration)
|
||||
res := h(c)
|
||||
select {
|
||||
case <-longRequestContext.Done():
|
||||
case <-c.Context().Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
return res
|
||||
}
|
||||
}
|
||||
anyProject := routes.WithMiddleware(
|
||||
storeNoticesInCookieMiddleware,
|
||||
loadCommonData,
|
||||
)
|
||||
hmnOnly := anyProject.WithMiddleware(
|
||||
redirectToHMN,
|
||||
)
|
||||
|
||||
routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
|
||||
AddCORSHeaders(c, &res)
|
||||
addCORSHeaders(c, &res)
|
||||
return res
|
||||
})
|
||||
routes.GET(hmnurl.RegexFishbowlFiles, FishbowlFiles)
|
||||
|
@ -189,10 +76,10 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
hmnOnly.POST(hmnurl.RegexDoPasswordReset, DoPasswordResetSubmit)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexAdminAtomFeed, AdminAtomFeed)
|
||||
hmnOnly.GET(hmnurl.RegexAdminApprovalQueue, adminMiddleware(AdminApprovalQueue))
|
||||
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminMiddleware(csrfMiddleware(AdminApprovalQueueSubmit)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminSetUserStatus, adminMiddleware(csrfMiddleware(UserProfileAdminSetStatus)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminNukeUser, adminMiddleware(csrfMiddleware(UserProfileAdminNuke)))
|
||||
hmnOnly.GET(hmnurl.RegexAdminApprovalQueue, adminsOnly(AdminApprovalQueue))
|
||||
hmnOnly.POST(hmnurl.RegexAdminApprovalQueue, adminsOnly(csrfMiddleware(AdminApprovalQueueSubmit)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminSetUserStatus, adminsOnly(csrfMiddleware(UserProfileAdminSetStatus)))
|
||||
hmnOnly.POST(hmnurl.RegexAdminNukeUser, adminsOnly(csrfMiddleware(UserProfileAdminNuke)))
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexFeed, Feed)
|
||||
hmnOnly.GET(hmnurl.RegexAtomFeed, AtomFeed)
|
||||
|
@ -200,19 +87,19 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexProjectNew, authMiddleware(ProjectNew))
|
||||
hmnOnly.POST(hmnurl.RegexProjectNew, authMiddleware(csrfMiddleware(ProjectNewSubmit)))
|
||||
hmnOnly.GET(hmnurl.RegexProjectNew, needsAuth(ProjectNew))
|
||||
hmnOnly.POST(hmnurl.RegexProjectNew, needsAuth(csrfMiddleware(ProjectNewSubmit)))
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, authMiddleware(csrfMiddleware(DiscordShowcaseBacklog)))
|
||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, needsAuth(DiscordOAuthCallback))
|
||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink)))
|
||||
hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog)))
|
||||
|
||||
hmnOnly.POST(hmnurl.RegexTwitchEventSubCallback, TwitchEventSubCallback)
|
||||
hmnOnly.GET(hmnurl.RegexTwitchDebugPage, TwitchDebugPage)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexUserProfile, UserProfile)
|
||||
hmnOnly.GET(hmnurl.RegexUserSettings, authMiddleware(UserSettings))
|
||||
hmnOnly.POST(hmnurl.RegexUserSettings, authMiddleware(csrfMiddleware(UserSettingsSave)))
|
||||
hmnOnly.GET(hmnurl.RegexUserSettings, needsAuth(UserSettings))
|
||||
hmnOnly.POST(hmnurl.RegexUserSettings, needsAuth(csrfMiddleware(UserSettingsSave)))
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexPodcast, PodcastIndex)
|
||||
hmnOnly.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
||||
|
@ -231,6 +118,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
|
||||
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
||||
|
||||
// Project routes can appear either at the root (e.g. hero.handmade.network/edit)
|
||||
// or on a personal project path (e.g. handmade.network/p/123/hero/edit). So, we
|
||||
// have pulled all those routes into this function.
|
||||
attachProjectRoutes := func(rb *RouteBuilder) {
|
||||
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
||||
if c.CurrentProject.IsHMN() {
|
||||
|
@ -240,8 +130,8 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
}
|
||||
})
|
||||
|
||||
rb.GET(hmnurl.RegexProjectEdit, authMiddleware(ProjectEdit))
|
||||
rb.POST(hmnurl.RegexProjectEdit, authMiddleware(csrfMiddleware(ProjectEditSubmit)))
|
||||
rb.GET(hmnurl.RegexProjectEdit, needsAuth(ProjectEdit))
|
||||
rb.POST(hmnurl.RegexProjectEdit, needsAuth(csrfMiddleware(ProjectEditSubmit)))
|
||||
|
||||
// Middleware used for forum action routes - anything related to actually creating or editing forum content
|
||||
needsForums := func(h Handler) Handler {
|
||||
|
@ -251,14 +141,14 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
return FourOhFour(c)
|
||||
}
|
||||
// Require auth if forums are enabled
|
||||
return authMiddleware(h)(c)
|
||||
return needsAuth(h)(c)
|
||||
}
|
||||
}
|
||||
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
|
||||
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
|
||||
rb.GET(hmnurl.RegexForumThread, ForumThread)
|
||||
rb.GET(hmnurl.RegexForum, Forum)
|
||||
rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
|
||||
rb.POST(hmnurl.RegexForumMarkRead, needsAuth(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
|
||||
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||
rb.GET(hmnurl.RegexForumPostReply, needsForums(ForumPostReply))
|
||||
rb.POST(hmnurl.RegexForumPostReply, needsForums(csrfMiddleware(ForumPostReplySubmit)))
|
||||
|
@ -276,7 +166,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
return FourOhFour(c)
|
||||
}
|
||||
// Require auth if blogs are enabled
|
||||
return authMiddleware(h)(c)
|
||||
return needsAuth(h)(c)
|
||||
}
|
||||
}
|
||||
rb.GET(hmnurl.RegexBlog, BlogIndex)
|
||||
|
@ -296,64 +186,10 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
), http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) {
|
||||
// TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems
|
||||
// pretty common to run the outermost middleware first before doing other stuff, but having
|
||||
// to nest functions this way feels real bad.
|
||||
rb.Middleware = func(h Handler) Handler {
|
||||
return hmnOnly.Middleware(func(c *RequestContext) ResponseData {
|
||||
// At this point we are definitely on the plain old HMN subdomain.
|
||||
|
||||
// Fetch personal project and do whatever
|
||||
id, err := strconv.Atoi(c.PathParams["projectid"])
|
||||
if err != nil {
|
||||
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
|
||||
}
|
||||
p, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
} else {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
|
||||
}
|
||||
}
|
||||
|
||||
c.CurrentProject = &p.Project
|
||||
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
|
||||
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, p.Owners)
|
||||
|
||||
if !p.Project.Personal {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(p.Project.Name) {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
})
|
||||
}
|
||||
attachProjectRoutes(rb)
|
||||
})
|
||||
anyProject.Group(regexp.MustCompile("^"), func(rb *RouteBuilder) {
|
||||
rb.Middleware = func(h Handler) Handler {
|
||||
return anyProject.Middleware(func(c *RequestContext) ResponseData {
|
||||
// We could be on any project's subdomain.
|
||||
|
||||
// Check if the current project (matched by subdomain) is actually no longer official
|
||||
// and therefore needs to be redirected to the personal project version of the route.
|
||||
if c.CurrentProject.Personal {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return h(c)
|
||||
})
|
||||
}
|
||||
attachProjectRoutes(rb)
|
||||
})
|
||||
officialProjectRoutes := anyProject.WithMiddleware(officialProjectMiddleware)
|
||||
personalProjectRoutes := hmnOnly.Group(hmnurl.RegexPersonalProject, personalProjectMiddleware)
|
||||
attachProjectRoutes(&officialProjectRoutes)
|
||||
attachProjectRoutes(&personalProjectRoutes)
|
||||
|
||||
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
||||
|
||||
|
@ -375,318 +211,69 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
|||
return router
|
||||
}
|
||||
|
||||
func ProjectCSS(c *RequestContext) ResponseData {
|
||||
color := c.URL().Query().Get("color")
|
||||
if color == "" {
|
||||
return c.ErrorResponse(http.StatusBadRequest, NewSafeError(nil, "You must provide a 'color' parameter.\n"))
|
||||
}
|
||||
|
||||
baseData := getBaseData(c, "", nil)
|
||||
|
||||
bgColor := noire.NewHex(color)
|
||||
h, s, l := bgColor.HSL()
|
||||
if baseData.Theme == "dark" {
|
||||
l = 15
|
||||
} else {
|
||||
l = 95
|
||||
}
|
||||
if s > 20 {
|
||||
s = 20
|
||||
}
|
||||
bgColor = noire.NewHSL(h, s, l)
|
||||
|
||||
templateData := struct {
|
||||
templates.BaseData
|
||||
Color string
|
||||
PostBgColor string
|
||||
}{
|
||||
BaseData: baseData,
|
||||
Color: color,
|
||||
PostBgColor: bgColor.HTML(),
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
res.Header().Add("Content-Type", "text/css")
|
||||
err := res.WriteTemplate("project.css", templateData, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to generate project CSS"))
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func FourOhFour(c *RequestContext) ResponseData {
|
||||
var res ResponseData
|
||||
res.StatusCode = http.StatusNotFound
|
||||
|
||||
if c.Req.Header["Accept"] != nil && strings.Contains(c.Req.Header["Accept"][0], "text/html") {
|
||||
templateData := struct {
|
||||
templates.BaseData
|
||||
Wanted string
|
||||
}{
|
||||
BaseData: getBaseData(c, "Page not found", nil),
|
||||
Wanted: c.FullUrl(),
|
||||
func setDBConn(conn *pgxpool.Pool) Middleware {
|
||||
return func(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
c.Conn = conn
|
||||
return h(c)
|
||||
}
|
||||
res.MustWriteTemplate("404.html", templateData, c.Perf)
|
||||
} else {
|
||||
res.Write([]byte("Not Found"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type RejectData struct {
|
||||
templates.BaseData
|
||||
RejectReason string
|
||||
}
|
||||
|
||||
func RejectRequest(c *RequestContext, reason string) ResponseData {
|
||||
var res ResponseData
|
||||
err := res.WriteTemplate("reject.html", RejectData{
|
||||
BaseData: getBaseData(c, "Rejected", nil),
|
||||
RejectReason: reason,
|
||||
}, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to render reject template"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
||||
defer c.Perf.EndBlock()
|
||||
|
||||
// get user
|
||||
{
|
||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||
if err == nil {
|
||||
user, session, err := getCurrentUserAndSession(c, sessionCookie.Value)
|
||||
if err != nil {
|
||||
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get current user"))
|
||||
}
|
||||
|
||||
c.CurrentUser = user
|
||||
c.CurrentSession = session
|
||||
func redirectToHMN(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
if !c.CurrentProject.IsHMN() {
|
||||
return c.Redirect(hmnurl.Url(c.URL().Path, hmnurl.QFromURL(c.URL())), http.StatusMovedPermanently)
|
||||
}
|
||||
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
||||
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
// get official project
|
||||
{
|
||||
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
||||
slug := strings.TrimRight(hostPrefix, ".")
|
||||
var owners []*models.User
|
||||
func officialProjectMiddleware(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
// Check if the current project (matched by subdomain) is actually no longer official
|
||||
// and therefore needs to be redirected to the personal project version of the route.
|
||||
if c.CurrentProject.Personal {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
if len(slug) > 0 {
|
||||
dbProject, err := hmndata.FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err == nil {
|
||||
c.CurrentProject = &dbProject.Project
|
||||
c.CurrentProjectLogoUrl = templates.ProjectLogoUrl(&dbProject.Project, dbProject.LogoLightAsset, dbProject.LogoDarkAsset, c.Theme)
|
||||
owners = dbProject.Owners
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
|
||||
func personalProjectMiddleware(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
hmnProject := c.CurrentProject
|
||||
|
||||
id := utils.Must1(strconv.Atoi(c.PathParams["projectid"]))
|
||||
p, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, id, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
} else {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
// do nothing, this is fine
|
||||
} else {
|
||||
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
||||
}
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
|
||||
}
|
||||
}
|
||||
|
||||
if c.CurrentProject == nil {
|
||||
dbProject, err := hmndata.FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, hmndata.ProjectsQuery{
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
})
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to fetch HMN project"))
|
||||
}
|
||||
c.CurrentProject = &dbProject.Project
|
||||
c.CurrentProjectLogoUrl = templates.ProjectLogoUrl(&dbProject.Project, dbProject.LogoLightAsset, dbProject.LogoDarkAsset, c.Theme)
|
||||
}
|
||||
|
||||
if c.CurrentProject == nil {
|
||||
panic("failed to load project data")
|
||||
}
|
||||
|
||||
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, owners)
|
||||
c.CurrentProject = &p.Project
|
||||
c.CurrentProject.Color1 = hmnProject.Color1
|
||||
c.CurrentProject.Color2 = hmnProject.Color2
|
||||
|
||||
c.UrlContext = hmndata.UrlContextForProject(c.CurrentProject)
|
||||
}
|
||||
c.CurrentUserCanEditCurrentProject = CanEditProject(c.CurrentUser, p.Owners)
|
||||
|
||||
c.Theme = "light"
|
||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||
c.Theme = "dark"
|
||||
}
|
||||
|
||||
return true, ResponseData{}
|
||||
}
|
||||
|
||||
func AddCORSHeaders(c *RequestContext, res *ResponseData) {
|
||||
parsed, err := url.Parse(config.Config.BaseUrl)
|
||||
if err != nil {
|
||||
c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
|
||||
return
|
||||
}
|
||||
origin := ""
|
||||
origins, found := c.Req.Header["Origin"]
|
||||
if found {
|
||||
origin = origins[0]
|
||||
}
|
||||
if strings.HasSuffix(origin, parsed.Host) {
|
||||
res.Header().Add("Access-Control-Allow-Origin", origin)
|
||||
res.Header().Add("Access-Control-Allow-Credentials", "true")
|
||||
res.Header().Add("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
|
||||
// Given a session id, fetches user data from the database. Will return nil if
|
||||
// the user cannot be found, and will only return an error if it's serious.
|
||||
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {
|
||||
session, err := auth.GetSession(c.Context(), c.Conn, sessionId)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrNoSession) {
|
||||
return nil, nil, nil
|
||||
} else {
|
||||
return nil, nil, oops.New(err, "failed to get current session")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, nil, session.Username, hmndata.UsersQuery{
|
||||
AnyStatus: true,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
||||
return nil, nil, nil // user was deleted or something
|
||||
} else {
|
||||
return nil, nil, oops.New(err, "failed to get user for session")
|
||||
}
|
||||
}
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func TrackRequestPerf(c *RequestContext) (after func()) {
|
||||
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
|
||||
c.ctx = context.WithValue(c.Context(), perf.PerfContextKey, c.Perf)
|
||||
|
||||
return func() {
|
||||
c.Perf.EndRequest()
|
||||
log := logging.Info()
|
||||
blockStack := make([]time.Time, 0)
|
||||
for i, block := range c.Perf.Blocks {
|
||||
for len(blockStack) > 0 && block.End.After(blockStack[len(blockStack)-1]) {
|
||||
blockStack = blockStack[:len(blockStack)-1]
|
||||
}
|
||||
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
|
||||
blockStack = append(blockStack, block.End)
|
||||
}
|
||||
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
|
||||
// perfCollector.SubmitRun(c.Perf) // TODO(asaf): Implement a use for this
|
||||
}
|
||||
}
|
||||
|
||||
func LogContextErrors(c *RequestContext, errs ...error) {
|
||||
for _, err := range errs {
|
||||
c.Logger.Error().Timestamp().Stack().Str("Requested", c.FullUrl()).Err(err).Msg("error occurred during request")
|
||||
}
|
||||
}
|
||||
|
||||
func LogContextErrorsFromResponse(c *RequestContext, res *ResponseData) {
|
||||
LogContextErrors(c, res.Errors...)
|
||||
}
|
||||
|
||||
func MiddlewarePanicCatcher(c *RequestContext, res *ResponseData) {
|
||||
if recovered := recover(); recovered != nil {
|
||||
maybeError, ok := recovered.(*error)
|
||||
var err error
|
||||
if ok {
|
||||
err = *maybeError
|
||||
} else {
|
||||
err = oops.New(nil, fmt.Sprintf("Recovered from panic with value: %v", recovered))
|
||||
}
|
||||
*res = c.ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
}
|
||||
|
||||
const NoticesCookieName = "hmn_notices"
|
||||
|
||||
func getNoticesFromCookie(c *RequestContext) []templates.Notice {
|
||||
cookie, err := c.Req.Cookie(NoticesCookieName)
|
||||
if err != nil {
|
||||
if !errors.Is(err, http.ErrNoCookie) {
|
||||
c.Logger.Warn().Err(err).Msg("failed to get notices cookie")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return deserializeNoticesFromCookie(cookie.Value)
|
||||
}
|
||||
|
||||
func storeNoticesInCookie(c *RequestContext, res *ResponseData) {
|
||||
serialized := serializeNoticesForCookie(c, res.FutureNotices)
|
||||
if serialized != "" {
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Value: serialized,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
Expires: time.Now().Add(time.Minute * 5),
|
||||
Secure: config.Config.Auth.CookieSecure,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
} else if !(res.StatusCode >= 300 && res.StatusCode < 400) {
|
||||
// NOTE(asaf): Don't clear on redirect
|
||||
noticesCookie := http.Cookie{
|
||||
Name: NoticesCookieName,
|
||||
Path: "/",
|
||||
Domain: config.Config.Auth.CookieDomain,
|
||||
MaxAge: -1,
|
||||
}
|
||||
res.SetCookie(¬icesCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func serializeNoticesForCookie(c *RequestContext, notices []templates.Notice) string {
|
||||
var builder strings.Builder
|
||||
maxSize := 1024 // NOTE(asaf): Make sure we don't use too much space for notices.
|
||||
size := 0
|
||||
for i, notice := range notices {
|
||||
sizeIncrease := len(notice.Class) + len(string(notice.Content)) + 1
|
||||
if i != 0 {
|
||||
sizeIncrease += 1
|
||||
}
|
||||
if size+sizeIncrease > maxSize {
|
||||
c.Logger.Warn().Interface("Notices", notices).Msg("Notices too big for cookie")
|
||||
break
|
||||
if !c.CurrentProject.Personal {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
if i != 0 {
|
||||
builder.WriteString("\t")
|
||||
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(c.CurrentProject.Name) {
|
||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||
}
|
||||
builder.WriteString(notice.Class)
|
||||
builder.WriteString("|")
|
||||
builder.WriteString(string(notice.Content))
|
||||
|
||||
size += sizeIncrease
|
||||
return h(c)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func deserializeNoticesFromCookie(cookieVal string) []templates.Notice {
|
||||
var result []templates.Notice
|
||||
notices := strings.Split(cookieVal, "\t")
|
||||
for _, notice := range notices {
|
||||
parts := strings.SplitN(notice, "|", 2)
|
||||
if len(parts) == 2 {
|
||||
result = append(result, templates.Notice{
|
||||
Class: parts[0],
|
||||
Content: template.HTML(parts[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ func TestLogContextErrors(t *testing.T) {
|
|||
Middleware: func(h Handler) Handler {
|
||||
return func(c *RequestContext) (res ResponseData) {
|
||||
c.Logger = &logger
|
||||
defer LogContextErrorsFromResponse(c, &res)
|
||||
defer logContextErrorsMiddleware(c, &res)
|
||||
return h(c)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -16,7 +16,7 @@ type ShowcaseData struct {
|
|||
}
|
||||
|
||||
func Showcase(c *RequestContext) ResponseData {
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{})
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ func Snippet(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
s, err := hmndata.FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, hmndata.SnippetQuery{})
|
||||
s, err := hmndata.FetchSnippet(c, c.Conn, c.CurrentUser, snippetId, hmndata.SnippetQuery{})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
|
|
|
@ -70,7 +70,7 @@ func TwitchEventSubCallback(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func TwitchDebugPage(c *RequestContext) ResponseData {
|
||||
streams, err := db.Query[models.TwitchStream](c.Context(), c.Conn,
|
||||
streams, err := db.Query[models.TwitchStream](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
|
|
@ -52,7 +52,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username {
|
||||
profileUser = c.CurrentUser
|
||||
} else {
|
||||
user, err := hmndata.FetchUserByUsername(c.Context(), c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
|
||||
user, err := hmndata.FetchUserByUsername(c, c.Conn, c.CurrentUser, username, hmndata.UsersQuery{})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
|
@ -72,7 +72,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch user links")
|
||||
userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
userLinks, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
|
@ -92,7 +92,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
projectsAndStuff, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
projectsAndStuff, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
|
||||
OwnerIDs: []int{profileUser.ID},
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
IncludeHidden: true,
|
||||
|
@ -111,13 +111,13 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch posts")
|
||||
posts, err := hmndata.FetchPosts(c.Context(), c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
posts, err := hmndata.FetchPosts(c, c.Conn, c.CurrentUser, hmndata.PostsQuery{
|
||||
UserIDs: []int{profileUser.ID},
|
||||
SortDescending: true,
|
||||
})
|
||||
c.Perf.EndBlock()
|
||||
|
||||
snippets, err := hmndata.FetchSnippets(c.Context(), c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
|
||||
OwnerIDs: []int{profileUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -125,7 +125,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
subforumTree := models.GetFullSubforumTree(c, c.Conn)
|
||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
|
@ -213,7 +213,7 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
DiscordShowcaseBacklogUrl string
|
||||
}
|
||||
|
||||
links, err := db.Query[models.Link](c.Context(), c.Conn,
|
||||
links, err := db.Query[models.Link](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM link
|
||||
|
@ -230,7 +230,7 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
|
||||
var tduser *templates.DiscordUser
|
||||
var numUnsavedMessages int
|
||||
duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
|
||||
duser, err := db.QueryOne[models.DiscordUser](c, c.Conn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM discord_user
|
||||
|
@ -246,7 +246,7 @@ func UserSettings(c *RequestContext) ResponseData {
|
|||
tmp := templates.DiscordUserToTemplate(duser)
|
||||
tduser = &tmp
|
||||
|
||||
numUnsavedMessages, err = db.QueryOneScalar[int](c.Context(), c.Conn,
|
||||
numUnsavedMessages, err = db.QueryOneScalar[int](c, c.Conn,
|
||||
`
|
||||
SELECT COUNT(*)
|
||||
FROM
|
||||
|
@ -299,11 +299,11 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
||||
}
|
||||
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
tx, err := c.Conn.Begin(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
defer tx.Rollback(c)
|
||||
|
||||
form, err := c.GetFormValues()
|
||||
if err != nil {
|
||||
|
@ -315,7 +315,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
|
||||
email := form.Get("email")
|
||||
if !hmnemail.IsEmail(email) {
|
||||
return RejectRequest(c, "Your email was not valid.")
|
||||
return c.RejectRequest("Your email was not valid.")
|
||||
}
|
||||
|
||||
showEmail := form.Get("showemail") != ""
|
||||
|
@ -328,7 +328,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
discordShowcaseAuto := form.Get("discord-showcase-auto") != ""
|
||||
discordDeleteSnippetOnMessageDelete := form.Get("discord-snippet-keep") == ""
|
||||
|
||||
_, err = tx.Exec(c.Context(),
|
||||
_, err = tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET
|
||||
|
@ -360,15 +360,15 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
// Process links
|
||||
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c.Context(), tx, &c.CurrentUser.ID, nil)
|
||||
twitchLoginsPreChange, preErr := hmndata.FetchTwitchLoginsForUserOrProject(c, tx, &c.CurrentUser.ID, nil)
|
||||
linksText := form.Get("links")
|
||||
links := ParseLinks(linksText)
|
||||
_, err = tx.Exec(c.Context(), `DELETE FROM link WHERE user_id = $1`, c.CurrentUser.ID)
|
||||
_, err = tx.Exec(c, `DELETE FROM link 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 {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
_, err := tx.Exec(c,
|
||||
`
|
||||
INSERT INTO link (name, url, ordering, user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
|
@ -384,7 +384,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
}
|
||||
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(c.Context(), tx, &c.CurrentUser.ID, nil)
|
||||
twitchLoginsPostChange, postErr := hmndata.FetchTwitchLoginsForUserOrProject(c, tx, &c.CurrentUser.ID, nil)
|
||||
if preErr == nil && postErr == nil {
|
||||
twitch.UserOrProjectLinksUpdated(twitchLoginsPreChange, twitchLoginsPostChange)
|
||||
}
|
||||
|
@ -407,7 +407,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
}
|
||||
var avatarUUID *uuid.UUID
|
||||
if newAvatar.Exists {
|
||||
avatarAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
|
||||
avatarAsset, err := assets.Create(c, tx, assets.CreateInput{
|
||||
Content: newAvatar.Content,
|
||||
Filename: newAvatar.Filename,
|
||||
ContentType: newAvatar.Mime,
|
||||
|
@ -421,7 +421,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
avatarUUID = &avatarAsset.ID
|
||||
}
|
||||
if newAvatar.Exists || newAvatar.Remove {
|
||||
_, err := tx.Exec(c.Context(),
|
||||
_, err := tx.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET
|
||||
|
@ -437,7 +437,7 @@ func UserSettingsSave(c *RequestContext) ResponseData {
|
|||
}
|
||||
}
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
err = tx.Commit(c)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save user settings"))
|
||||
}
|
||||
|
@ -454,7 +454,7 @@ func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
|||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "No user id provided")
|
||||
return c.RejectRequest("No user id provided")
|
||||
}
|
||||
|
||||
status := c.Req.Form.Get("status")
|
||||
|
@ -469,10 +469,10 @@ func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
|||
case "banned":
|
||||
desiredStatus = models.UserStatusBanned
|
||||
default:
|
||||
return RejectRequest(c, "No legal user status provided")
|
||||
return c.RejectRequest("No legal user status provided")
|
||||
}
|
||||
|
||||
_, err = c.Conn.Exec(c.Context(),
|
||||
_, err = c.Conn.Exec(c,
|
||||
`
|
||||
UPDATE hmn_user
|
||||
SET status = $1
|
||||
|
@ -485,7 +485,7 @@ func UserProfileAdminSetStatus(c *RequestContext) ResponseData {
|
|||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update user status"))
|
||||
}
|
||||
if desiredStatus == models.UserStatusBanned {
|
||||
err = auth.DeleteSessionForUser(c.Context(), c.Conn, c.Req.Form.Get("username"))
|
||||
err = auth.DeleteSessionForUser(c, c.Conn, c.Req.Form.Get("username"))
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to log out user"))
|
||||
}
|
||||
|
@ -500,10 +500,10 @@ func UserProfileAdminNuke(c *RequestContext) ResponseData {
|
|||
userIdStr := c.Req.Form.Get("user_id")
|
||||
userId, err := strconv.Atoi(userIdStr)
|
||||
if err != nil {
|
||||
return RejectRequest(c, "No user id provided")
|
||||
return c.RejectRequest("No user id provided")
|
||||
}
|
||||
|
||||
err = deleteAllPostsForUser(c.Context(), c.Conn, userId)
|
||||
err = deleteAllPostsForUser(c, c.Conn, userId)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete user posts"))
|
||||
}
|
||||
|
@ -514,7 +514,7 @@ func UserProfileAdminNuke(c *RequestContext) ResponseData {
|
|||
|
||||
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.")
|
||||
res := c.RejectRequest("Your password and password confirmation did not match.")
|
||||
return &res
|
||||
}
|
||||
|
||||
|
@ -531,12 +531,12 @@ func updatePassword(c *RequestContext, tx pgx.Tx, old, new, confirm string) *Res
|
|||
}
|
||||
|
||||
if !ok {
|
||||
res := RejectRequest(c, "The old password you provided was not correct.")
|
||||
res := c.RejectRequest("The old password you provided was not correct.")
|
||||
return &res
|
||||
}
|
||||
|
||||
newHashedPassword := auth.HashPassword(new)
|
||||
err = auth.UpdatePassword(c.Context(), tx, c.CurrentUser.Username, newHashedPassword)
|
||||
err = auth.UpdatePassword(c, tx, c.CurrentUser.Username, newHashedPassword)
|
||||
if err != nil {
|
||||
res := c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to update password"))
|
||||
return &res
|
||||
|
|
|
@ -33,14 +33,13 @@ var WebsiteCommand = &cobra.Command{
|
|||
logging.Info().Msg("Hello, HMN!")
|
||||
|
||||
backgroundJobContext, cancelBackgroundJobs := context.WithCancel(context.Background())
|
||||
longRequestContext, cancelLongRequests := context.WithCancel(context.Background())
|
||||
|
||||
conn := db.NewConnPool()
|
||||
perfCollector := perf.RunPerfCollector(backgroundJobContext)
|
||||
|
||||
server := http.Server{
|
||||
Addr: config.Config.Addr,
|
||||
Handler: NewWebsiteRoutes(longRequestContext, conn),
|
||||
Handler: NewWebsiteRoutes(conn),
|
||||
}
|
||||
|
||||
backgroundJobsDone := jobs.Zip(
|
||||
|
@ -59,8 +58,6 @@ var WebsiteCommand = &cobra.Command{
|
|||
<-signals
|
||||
logging.Info().Msg("Shutting down the website")
|
||||
go func() {
|
||||
logging.Info().Msg("cancelling long requests")
|
||||
cancelLongRequests()
|
||||
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
logging.Info().Msg("shutting down web server")
|
||||
|
|