From 3921db6166e2da895257496bb76dd115556699d3 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Sat, 29 Mar 2025 11:12:53 +0300 Subject: init --- .gitignore | 7 + Makefile | 35 ++ README.md | 9 + assets/favicon/wolfhead_negated.ico | Bin 0 -> 3230 bytes assets/helpers.js | 14 + assets/htmx.min.js | 1 + assets/htmx.sse.js | 355 +++++++++++++++++++++ assets/style.css | 47 +++ cmd/root.go | 46 +++ cmd/start.go | 47 +++ components/auth.html | 22 ++ components/error.html | 9 + components/index.html | 28 ++ config/config.go | 45 +++ go.mod | 38 +++ go.sum | 91 ++++++ internal/crons/main.go | 88 +++++ internal/database/migrations/001_init.down.sql | 4 + internal/database/migrations/001_init.up.sql | 25 ++ internal/database/migrations/002_defaults.down.sql | 5 + internal/database/migrations/002_defaults.up.sql | 11 + internal/database/migrations/init.go | 3 + internal/database/migrations/migrations.bindata.go | 267 ++++++++++++++++ internal/database/repos/defaults.go | 25 ++ internal/database/repos/main.go | 19 ++ internal/database/sql/main.go | 88 +++++ internal/handlers/auth.go | 162 ++++++++++ internal/handlers/main.go | 64 ++++ internal/handlers/middleware.go | 75 +++++ internal/models/auth.go | 24 ++ internal/models/models.go | 45 +++ internal/server/main.go | 59 ++++ internal/server/router.go | 31 ++ main.go | 14 + pkg/cache/interface.go | 9 + pkg/cache/main.go | 146 +++++++++ pkg/utils/main.go | 24 ++ 37 files changed, 1982 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 assets/favicon/wolfhead_negated.ico create mode 100644 assets/helpers.js create mode 100644 assets/htmx.min.js create mode 100644 assets/htmx.sse.js create mode 100644 assets/style.css create mode 100644 cmd/root.go create mode 100644 cmd/start.go create mode 100644 components/auth.html create mode 100644 components/error.html create mode 100644 components/index.html create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/crons/main.go create mode 100644 internal/database/migrations/001_init.down.sql create mode 100644 internal/database/migrations/001_init.up.sql create mode 100644 internal/database/migrations/002_defaults.down.sql create mode 100644 internal/database/migrations/002_defaults.up.sql create mode 100644 internal/database/migrations/init.go create mode 100644 internal/database/migrations/migrations.bindata.go create mode 100644 internal/database/repos/defaults.go create mode 100644 internal/database/repos/main.go create mode 100644 internal/database/sql/main.go create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/main.go create mode 100644 internal/handlers/middleware.go create mode 100644 internal/models/auth.go create mode 100644 internal/models/models.go create mode 100644 internal/server/main.go create mode 100644 internal/server/router.go create mode 100644 main.go create mode 100644 pkg/cache/interface.go create mode 100644 pkg/cache/main.go create mode 100644 pkg/utils/main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff7a93d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +secrethitler +config.yml +demoon +tags +store.json +apj.db +.aider* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..71d46fa --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +.PHONY: all init deps install test lint run stop + +run: + go build + ./demoon start + +init: + go mod init + +# install all dependencies used by the application +deps: + go clean -modcache + go mod download + +# install the application in the Go bin/ folder +install: + go install ./... + +test: + go test ./... + +lint: + golangci-lint run --config .golangci.yml + +gen: + go generate ./... + +build-container: + docker build -t demoon:master . + +stop-container: + docker rm -f demoon 2>/dev/null && echo "old container removed" + +run-container: stop-container + docker run --name=demoon -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:9000:9000 -d demoon:master diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb4085b --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +### Goserver implementation for action points journal + +### Notes +- Two types of actions exists: that of earning points and ones that spend it; +- Cookie use for authentication; +- Public|private journals; +- Auto name with option to save (tether to email) and change; +- History of actions; + diff --git a/assets/favicon/wolfhead_negated.ico b/assets/favicon/wolfhead_negated.ico new file mode 100644 index 0000000..ed5545a Binary files /dev/null and b/assets/favicon/wolfhead_negated.ico differ diff --git a/assets/helpers.js b/assets/helpers.js new file mode 100644 index 0000000..9d6749c --- /dev/null +++ b/assets/helpers.js @@ -0,0 +1,14 @@ +function copyText() { + // Get the text field + var roomLink = document.getElementById("roomlink"); + + // Select the text field + roomLink.select(); + roomLink.setSelectionRange(0, 99999); // For mobile devices + + // Copy the text inside the text field + navigator.clipboard.writeText(roomLink.value); + + // Alert the copied text + alert("Copied the text: " + roomLink.value); +} diff --git a/assets/htmx.min.js b/assets/htmx.min.js new file mode 100644 index 0000000..1463bc3 --- /dev/null +++ b/assets/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.10"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function a(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);return i.querySelector("template").content}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return a(""+n+"
",1);case"col":return a(""+n+"
",2);case"tr":return a(""+n+"
",2);case"td":case"th":return a(""+n+"
",3);case"script":case"style":return a("
"+n+"
",1);default:return a(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=g(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=g(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=g(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=g(e);e.classList.toggle(t)}function W(e,t){e=g(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=g(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function s(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(s(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function g(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:g(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var me=re().createElement("output");function pe(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[me]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function m(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){mt(e)})}},200)}}function mt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function pt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){p(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);mt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=pe(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=pe(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return pr(n)}else{return mr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:g(r),returnPromise:true})}else{return he(e,t,g(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:g(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=s(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==me){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var m=ne(n,"hx-sync");var p=null;var x=false;if(m){var B=m.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}m=(B[1]||"drop").trim();f=ae(g);if(m==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(m==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(m==="replace"){ce(g,"htmx:abort")}else if(m.indexOf("queue")===0){var V=m.split(" ");p=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(p==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){p=y.triggerSpec.queue}}if(p==null){p="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(p==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(p==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=mr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var m=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:m},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;m=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){m=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var p=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!m){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(p)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){p=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); diff --git a/assets/htmx.sse.js b/assets/htmx.sse.js new file mode 100644 index 0000000..943d80a --- /dev/null +++ b/assets/htmx.sse.js @@ -0,0 +1,355 @@ +/* +Server Sent Events Extension +============================ +This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. + +*/ + +(function() { + + /** @type {import("../htmx").HtmxInternalApi} */ + var api; + + htmx.defineExtension("sse", { + + /** + * Init saves the provided reference to the internal HTMX API. + * + * @param {import("../htmx").HtmxInternalApi} api + * @returns void + */ + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef; + + // set a function in the public API for creating new EventSource objects + if (htmx.createEventSource == undefined) { + htmx.createEventSource = createEventSource; + } + }, + + /** + * onEvent handles all events passed to this extension. + * + * @param {string} name + * @param {Event} evt + * @returns void + */ + onEvent: function(name, evt) { + + switch (name) { + + case "htmx:beforeCleanupElement": + var internalData = api.getInternalData(evt.target) + // Try to remove remove an EventSource when elements are removed + if (internalData.sseEventSource) { + internalData.sseEventSource.close(); + } + + return; + + // Try to create EventSources when elements are processed + case "htmx:afterProcessNode": + ensureEventSourceOnElement(evt.target); + registerSSE(evt.target); + } + } + }); + + /////////////////////////////////////////////// + // HELPER FUNCTIONS + /////////////////////////////////////////////// + + + /** + * createEventSource is the default method for creating new EventSource objects. + * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. + * + * @param {string} url + * @returns EventSource + */ + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }); + } + + function splitOnWhitespace(trigger) { + return trigger.trim().split(/\s+/); + } + + function getLegacySSEURL(elt) { + var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); + if (legacySSEValue) { + var values = splitOnWhitespace(legacySSEValue); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "connect") { + return value[1]; + } + } + } + } + + function getLegacySSESwaps(elt) { + var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); + var returnArr = []; + if (legacySSEValue != null) { + var values = splitOnWhitespace(legacySSEValue); + for (var i = 0; i < values.length; i++) { + var value = values[i].split(/:(.+)/); + if (value[0] === "swap") { + returnArr.push(value[1]); + } + } + } + return returnArr; + } + + /** + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * + * @param {HTMLElement} elt + */ + function registerSSE(elt) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource); + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null; // no eventsource in parentage, orphaned element + } + + // Set internalData and source + var internalData = api.getInternalData(sourceElement); + var source = internalData.sseEventSource; + + // Add message handlers for every `sse-swap` attribute + queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { + + var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); + if (sseSwapAttr) { + var sseEventNames = sseSwapAttr.split(","); + } else { + var sseEventNames = getLegacySSESwaps(child); + } + + for (var i = 0; i < sseEventNames.length; i++) { + var sseEventName = sseEventNames[i].trim(); + var listener = function(event) { + + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { + return; + } + + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + + // swap the response into the DOM and trigger a notification + swap(child, event.data); + api.triggerEvent(elt, "htmx:sseMessage", event); + }; + + // Register the new listener + api.getInternalData(child).sseEventListener = listener; + source.addEventListener(sseEventName, listener); + } + }); + + // Add message handlers for every `hx-trigger="sse:*"` attribute + queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { + + var sseEventName = api.getAttributeValue(child, "hx-trigger"); + if (sseEventName == null) { + return; + } + + // Only process hx-triggers for events with the "sse:" prefix + if (sseEventName.slice(0, 4) != "sse:") { + return; + } + + // remove the sse: prefix from here on out + sseEventName = sseEventName.substr(4); + + var listener = function() { + if (maybeCloseSSESource(sourceElement)) { + return + } + + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + } + }); + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + + if (elt == null) { + return null; + } + + // handle extension source creation attribute + queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { + var sseURL = api.getAttributeValue(child, "sse-connect"); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + // handle legacy sse, remove for HTMX2 + queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { + var sseURL = getLegacySSEURL(child); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url); + + source.onerror = function(err) { + + // Log an error event + api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return; + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0; + var timeout = Math.random() * (2 ^ retryCount) * 500; + window.setTimeout(function() { + ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); + }, timeout); + } + }; + + source.onopen = function(evt) { + api.triggerEvent(elt, "htmx:sseOpen", { source: source }); + } + + api.getInternalData(elt).sseEventSource = source; + } + + /** + * maybeCloseSSESource confirms that the parent element still exists. + * If not, then any associated SSE source is closed and the function returns true. + * + * @param {HTMLElement} elt + * @returns boolean + */ + function maybeCloseSSESource(elt) { + if (!api.bodyContains(elt)) { + var source = api.getInternalData(elt).sseEventSource; + if (source != undefined) { + source.close(); + // source = null + return true; + } + } + return false; + } + + /** + * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. + * + * @param {HTMLElement} elt + * @param {string} attributeName + */ + function queryAttributeOnThisOrChildren(elt, attributeName) { + + var result = []; + + // If the parent element also contains the requested attribute, then add it to the results too. + if (api.hasAttribute(elt, attributeName)) { + result.push(elt); + } + + // Search all child nodes that match the requested attribute + elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { + result.push(node); + }); + + return result; + } + + /** + * @param {HTMLElement} elt + * @param {string} content + */ + function swap(elt, content) { + + api.withExtensions(elt, function(extension) { + content = extension.transformResponse(content, null, elt); + }); + + var swapSpec = api.getSwapSpecification(elt); + var target = api.getTarget(elt); + var settleInfo = api.makeSettleInfo(elt); + + api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.add(htmx.config.settlingClass); + } + api.triggerEvent(elt, 'htmx:beforeSettle'); + }); + + // Handle settle tasks (with delay if requested) + if (swapSpec.settleDelay > 0) { + setTimeout(doSettle(settleInfo), swapSpec.settleDelay); + } else { + doSettle(settleInfo)(); + } + } + + /** + * doSettle mirrors much of the functionality in htmx that + * settles elements after their content has been swapped. + * TODO: this should be published by htmx, and not duplicated here + * @param {import("../htmx").HtmxSettleInfo} settleInfo + * @returns () => void + */ + function doSettle(settleInfo) { + + return function() { + settleInfo.tasks.forEach(function(task) { + task.call(); + }); + + settleInfo.elts.forEach(function(elt) { + if (elt.classList) { + elt.classList.remove(htmx.config.settlingClass); + } + api.triggerEvent(elt, 'htmx:afterSettle'); + }); + } + } + + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null; + } + +})(); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..d71d3fd --- /dev/null +++ b/assets/style.css @@ -0,0 +1,47 @@ +body{ + background-color: #0C1616FF; + color: #8896b2; + max-width: 800px; + min-width: 0px; + margin: 2em auto !important; + margin-left: auto; + margin-right: auto; + line-height: 1.5; + font-size: 16px; + font-family: Open Sans,Arial; + text-align: center; + display: block; +} +a{ + color: #00a2e7; +} +a:visited{ + color: #ca1a70; +} +table{ + border-collapse: separate !important; + border-spacing: 10px 10px; + border: 1px solid white; +} +tr{ + border: 1px solid white; +} +#usertable{ + display: block ruby; +} +.actiontable{ + display: inline flow-root; + margin-inline: 10px; +} +.action_name{ + border: none; + display: inline; + font-family: inherit; + font-size: inherit; + padding: none; + width: auto; +} +#errorbox{ + border: 1px solid black; + background-color: darkorange; +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1048bf2 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "log" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// LogLevel Flag +var LogLevel = "info" +var LogFormat = "json" +var cfgFile string +var rootCmd = &cobra.Command{ + Use: "demoon", + Short: "demoon", +} + +func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().StringVar(&cfgFile, + "config", "", "config file (default is ./config.yml)") + viper.SetConfigName("config") + viper.AddConfigPath(".") // First try to load the config from the current directory + viper.AddConfigPath("$HOME") // Then try to load it from the HOME directory + viper.AddConfigPath("/etc/demoon/") // As a last resort try to load it from the /etc/ +} + +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } + viper.SetEnvPrefix("CFG") + viper.AutomaticEnv() + if err := viper.ReadInConfig(); err != nil { + log.Fatal("Can't read configuration file", "error", err) + } +} + +// Execute the commands +func Execute() { + if err := rootCmd.Execute(); err != nil { + log.Fatal("failed to execute", "error", err) + } +} diff --git a/cmd/start.go b/cmd/start.go new file mode 100644 index 0000000..af46d79 --- /dev/null +++ b/cmd/start.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "demoon/config" + "demoon/internal/crons" + "demoon/internal/database/repos" + database "demoon/internal/database/sql" + "demoon/internal/server" + "context" + "os" + + "log/slog" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + rootCmd.AddCommand(startCmd) +} + +var startCmd = &cobra.Command{ + Use: "start", + Short: "Start data server", + Long: `Start data server`, + Run: func(cmd *cobra.Command, args []string) { + log := slog.New(slog.NewJSONHandler(os.Stdout, + &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true})) + // load server configuration from server + if viper.ConfigFileUsed() != "" { + log.Debug("Configuration file loaded", "section", "init", + "path", viper.ConfigFileUsed()) + } + cfg := config.LoadConfig(viper.GetViper()) + db, err := database.Init(cfg.DBURI) + if err != nil { + log.Error("failed to connect to db", "error", err) + return + } + repo := repos.NewProvider(db.Conn) + srv := server.NewServer(cfg, log, repo) + cron := crons.NewCron(context.Background(), repo) + cron.StartCronJobs() + // listen for new messages + srv.Listen() + }, +} diff --git a/components/auth.html b/components/auth.html new file mode 100644 index 0000000..5122919 --- /dev/null +++ b/components/auth.html @@ -0,0 +1,22 @@ +{{define "auth"}} +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+{{end}} diff --git a/components/error.html b/components/error.html new file mode 100644 index 0000000..2fe8b70 --- /dev/null +++ b/components/error.html @@ -0,0 +1,9 @@ +{{define "error"}} + + + +{{end}} diff --git a/components/index.html b/components/index.html new file mode 100644 index 0000000..4562550 --- /dev/null +++ b/components/index.html @@ -0,0 +1,28 @@ +{{define "main"}} + + + + Action Points Journal + + + + + + +
+ {{ if not .Username }} +
+ {{ template "auth" }} +
+ {{ else }} +
+ hello user +
+
+ some button +
+ {{ end }} +
+ + +{{end}} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..36b61a8 --- /dev/null +++ b/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "log/slog" + "os" + + "github.com/spf13/viper" +) + +type Config struct { + ServerConfig ServerConfig `mapstructure:"SERVICE"` + BaseURL string `mapstructure:"BASE_URL"` + SessionLifetime int `mapstructure:"SESSION_LIFETIME_SECONDS"` + DBURI string `mapstructure:"DBURI"` + CookieSecret string `mapstructure:"COOKIE_SECRET"` +} + +type ServerConfig struct { + Host string `mapstructure:"HOST"` + Port string `mapstructure:"PORT"` +} + +// LoadConfig Load server configuration from the yaml file +func LoadConfig(viperConf *viper.Viper) Config { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + var config Config + + err := viperConf.Unmarshal(&config) + if err != nil { + logger.Error("Unable to decode configuration file", "error", err) + } + return config +} + +// OpenConfig open config file +func OpenConfig() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + viper.SetConfigType("yml") + viper.SetConfigName("config") + viper.SetEnvPrefix("CFG") + err := viper.ReadInConfig() // Find and read the config file + if err != nil { // Handle errors reading the config file + logger.Error("Unable to read configuration file", "error", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7ee017e --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module demoon + +go 1.23 + +require ( + github.com/jmoiron/sqlx v1.3.5 + github.com/mattn/go-sqlite3 v1.14.6 + github.com/pkg/errors v0.9.1 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 + golang.org/x/crypto v0.16.0 +) + +require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b5c56c1 --- /dev/null +++ b/go.sum @@ -0,0 +1,91 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/crons/main.go b/internal/crons/main.go new file mode 100644 index 0000000..6649992 --- /dev/null +++ b/internal/crons/main.go @@ -0,0 +1,88 @@ +package crons + +import ( + "demoon/internal/database/repos" + "context" + "os" + "strconv" + "time" + + "log/slog" +) + +var ( + log = slog.New(slog.NewJSONHandler(os.Stdout, + &slog.HandlerOptions{Level: slog.LevelDebug})) +) + +const ( + CheckBurnTimeKey = "check_burn_time_key" +) + +type Cron struct { + ctx context.Context + repo repos.FullRepo +} + +func NewCron( + ctx context.Context, repo repos.FullRepo, +) *Cron { + return &Cron{ + ctx: ctx, + repo: repo, + } +} + +func (c *Cron) UpdateDefaultsFloat64(defaults map[string]float64) (map[string]float64, error) { + dm, err := c.repo.DBGetDefaultsMap() + if err != nil { + return defaults, err + } + for k, _ := range defaults { + sosValue, ok := dm[k] + if ok { + value, err := strconv.ParseFloat(sosValue, 64) + if err != nil { + // log err + continue + } + defaults[k] = value + } + } + return defaults, nil +} + +func (c *Cron) StartCronJobs() { + // check system_options for time + defaults := map[string]float64{ + CheckBurnTimeKey: 5.0, + } + defaults, err := c.UpdateDefaultsFloat64(defaults) + if err != nil { + panic(err) + } + go c.BurnTicker( + time.Minute * time.Duration(defaults[CheckBurnTimeKey])) +} + +func (c *Cron) BurnTicker(interval time.Duration) { + funcname := "BurnTicker" + ticker := time.NewTicker(interval) + for { + select { + case <-c.ctx.Done(): + log.Info("cron stopped by ctx.Done", "func", funcname) + return + case <-ticker.C: + c.BurnTimeUpdate() + } + } +} + +func (c *Cron) BurnTimeUpdate() { + // funcname := "BurnTimeUpdate" + // get all user scores + // if burn time < now + // halv the score + // move the burn time to 24h from now (using same hours, minutes, seconds) +} diff --git a/internal/database/migrations/001_init.down.sql b/internal/database/migrations/001_init.down.sql new file mode 100644 index 0000000..41cc5c9 --- /dev/null +++ b/internal/database/migrations/001_init.down.sql @@ -0,0 +1,4 @@ +BEGIN TRANSACTION; +DROP SCHEMA public CASCADE; +CREATE SCHEMA public; +COMMIT; diff --git a/internal/database/migrations/001_init.up.sql b/internal/database/migrations/001_init.up.sql new file mode 100644 index 0000000..141a045 --- /dev/null +++ b/internal/database/migrations/001_init.up.sql @@ -0,0 +1,25 @@ +BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS user_score ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + burn_time TIMESTAMP NOT NULL, + score SMALLINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS action( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + magnitude SMALLINT NOT NULL DEFAULT 1, + repeatable BOOLEAN NOT NULL DEFAULT FALSE, + type TEXT NOT NULL, + done BOOLEAN NOT NULL DEFAULT FALSE, + username TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(username, name), + CONSTRAINT fk_user_score + FOREIGN KEY(username) + REFERENCES user_score(username) +); +COMMIT; diff --git a/internal/database/migrations/002_defaults.down.sql b/internal/database/migrations/002_defaults.down.sql new file mode 100644 index 0000000..3e38fa2 --- /dev/null +++ b/internal/database/migrations/002_defaults.down.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +DROP TABLE defaults; + +COMMIT; diff --git a/internal/database/migrations/002_defaults.up.sql b/internal/database/migrations/002_defaults.up.sql new file mode 100644 index 0000000..4bf42b7 --- /dev/null +++ b/internal/database/migrations/002_defaults.up.sql @@ -0,0 +1,11 @@ +BEGIN TRANSACTION; + +CREATE TABLE IF NOT EXISTS defaults ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; diff --git a/internal/database/migrations/init.go b/internal/database/migrations/init.go new file mode 100644 index 0000000..2b5a212 --- /dev/null +++ b/internal/database/migrations/init.go @@ -0,0 +1,3 @@ +package migrations + +//go:generate go-bindata -o ./migrations.bindata.go -pkg migrations -ignore=\\*.go ./... diff --git a/internal/database/migrations/migrations.bindata.go b/internal/database/migrations/migrations.bindata.go new file mode 100644 index 0000000..a3276ee --- /dev/null +++ b/internal/database/migrations/migrations.bindata.go @@ -0,0 +1,267 @@ +// Code generated for package migrations by go-bindata DO NOT EDIT. (@generated) +// sources: +// 001_init.down.sql +// 001_init.up.sql +package migrations + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +func bindataRead(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + clErr := gz.Close() + + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + if clErr != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +// Name return file name +func (fi bindataFileInfo) Name() string { + return fi.name +} + +// Size return file size +func (fi bindataFileInfo) Size() int64 { + return fi.size +} + +// Mode return file mode +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} + +// Mode return file modify time +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} + +// IsDir return file whether a directory +func (fi bindataFileInfo) IsDir() bool { + return fi.mode&os.ModeDir != 0 +} + +// Sys return file is sys mode +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var __001_initDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x72\x75\xf7\xf4\xb3\xe6\x72\x09\xf2\x0f\x50\x08\x76\xf6\x70\xf5\x75\x54\x28\x28\x4d\xca\xc9\x4c\x56\x70\x76\x0c\x76\x76\x74\x71\xb5\xe6\x72\x0e\x72\x75\x0c\x71\x45\x95\xb5\xe6\x72\xf6\xf7\xf5\xf5\x0c\xb1\xe6\x02\x04\x00\x00\xff\xff\x47\x78\xff\x61\x41\x00\x00\x00") + +func _001_initDownSqlBytes() ([]byte, error) { + return bindataRead( + __001_initDownSql, + "001_init.down.sql", + ) +} + +func _001_initDownSql() (*asset, error) { + bytes, err := _001_initDownSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "001_init.down.sql", size: 65, mode: os.FileMode(420), modTime: time.Unix(1712559447, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var __001_initUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x92\x41\x6f\x82\x40\x10\x85\xef\xfc\x8a\x77\x53\x53\x2f\x9e\x3d\x2d\x38\x98\x4d\x97\xdd\x16\x96\xb4\x9e\xc8\x2a\xdb\x86\x54\xd1\xe0\xda\xc4\x7f\xdf\xe0\x5a\x28\xa9\x49\x1b\xcf\xf3\xcd\x9b\xf7\x66\x26\xa4\x25\x97\xf3\x20\x4a\x89\x69\x82\x66\xa1\x20\x9c\x8e\xb6\x29\x8e\x9b\x7d\x63\x31\x0e\x00\xa0\x2a\xc1\xa5\xc6\x92\x24\xa5\x4c\xd3\x02\xe1\x0a\x0b\x8a\x59\x2e\x34\x58\x06\xbe\x20\xa9\xb9\x5e\x4d\x2f\x70\xdb\x5d\x9b\x9d\x85\xa6\x57\x8d\x5c\xf2\xe7\x9c\x20\x95\x86\xcc\x85\xf0\xc8\xfa\xd4\xd4\x85\xab\x5a\x86\x27\x94\x69\x96\x3c\x75\x44\x27\x2c\xd5\xcb\x78\x82\x07\x54\xb5\xb3\xcd\xa7\xd9\x62\x34\x43\x69\xce\x23\x2f\xe1\xed\x65\x09\x13\xa2\xb5\x36\xd4\xdf\x34\xd6\x38\x5b\x16\xc6\xfd\x39\x20\x98\xcc\x83\x61\x7a\xb3\x71\xd5\xbe\xbe\x27\x79\x9f\x7a\x68\x67\x67\xde\xeb\xca\x9d\xca\x1b\x7e\x3b\xb1\x99\x47\x1b\x7b\xb0\xc6\x99\xf5\xd6\x22\x54\x4a\x10\x93\xbf\xd1\x98\x89\x8c\x3c\xee\xce\x87\x9b\x13\xcb\x7d\xfd\x3f\x81\xe1\xb1\xee\xde\xa2\x6f\xf0\xb7\x1e\x7f\x6b\x4e\x2f\x0b\xb9\xd6\x22\x25\x33\x9d\xb2\x36\xfb\xdb\x47\xd1\x7f\xd8\xa5\x08\xc4\x2a\x25\xbe\x94\x78\xa4\x55\xd7\x3f\xc1\xb5\x98\x52\x4c\x29\xc9\x88\xb2\x1f\xaf\xd9\x63\xed\x09\x23\x95\x24\x5c\xcf\x83\xaf\x00\x00\x00\xff\xff\x9f\xe9\x59\xfc\xcf\x02\x00\x00") + +func _001_initUpSqlBytes() ([]byte, error) { + return bindataRead( + __001_initUpSql, + "001_init.up.sql", + ) +} + +func _001_initUpSql() (*asset, error) { + bytes, err := _001_initUpSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "001_init.up.sql", size: 719, mode: os.FileMode(420), modTime: time.Unix(1712558242, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "001_init.down.sql": _001_initDownSql, + "001_init.up.sql": _001_initUpSql, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "001_init.down.sql": &bintree{_001_initDownSql, map[string]*bintree{}}, + "001_init.up.sql": &bintree{_001_initUpSql, map[string]*bintree{}}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} diff --git a/internal/database/repos/defaults.go b/internal/database/repos/defaults.go new file mode 100644 index 0000000..e0b5e52 --- /dev/null +++ b/internal/database/repos/defaults.go @@ -0,0 +1,25 @@ +package repos + +type DefaultsRepo interface { + DBGetDefaultsMap() (map[string]string, error) +} + +func (p *Provider) DBGetDefaultsMap() (map[string]string, error) { + rows, err := p.db.Queryx(`SELECT key, value + FROM defaults; + `) + if err != nil { + return nil, err + } + res := make(map[string]string) + for rows.Next() { + keyval, err := rows.SliceScan() + if err != nil { + return nil, err + } + key := keyval[0].(string) + value := keyval[1].(string) + res[key] = value + } + return res, nil +} diff --git a/internal/database/repos/main.go b/internal/database/repos/main.go new file mode 100644 index 0000000..e57855f --- /dev/null +++ b/internal/database/repos/main.go @@ -0,0 +1,19 @@ +package repos + +import ( + "github.com/jmoiron/sqlx" +) + +type FullRepo interface { + DefaultsRepo +} + +type Provider struct { + db *sqlx.DB +} + +func NewProvider(conn *sqlx.DB) *Provider { + return &Provider{ + db: conn, + } +} diff --git a/internal/database/sql/main.go b/internal/database/sql/main.go new file mode 100644 index 0000000..5a523f6 --- /dev/null +++ b/internal/database/sql/main.go @@ -0,0 +1,88 @@ +package database + +import ( + "os" + "time" + + "log/slog" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/pkg/errors" +) + +var ( + log = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + dbDriver = "sqlite3" +) + +type DB struct { + Conn *sqlx.DB + URI string +} + +func (d *DB) CloseAll() error { + for _, conn := range []*sqlx.DB{d.Conn} { + if err := closeConn(conn); err != nil { + return err + } + } + return nil +} + +func closeConn(conn *sqlx.DB) error { + return conn.Close() +} + +func Init(DBURI string) (*DB, error) { + var result DB + var err error + result.Conn, err = openDBConnection(DBURI, dbDriver) + if err != nil { + return nil, err + } + result.URI = DBURI + if err := testConnection(result.Conn); err != nil { + return nil, err + } + return &result, nil +} + +func openDBConnection(dbURI, driver string) (*sqlx.DB, error) { + conn, err := sqlx.Open(driver, dbURI) + if err != nil { + return nil, err + } + return conn, nil +} + +func testConnection(conn *sqlx.DB) error { + err := conn.Ping() + if err != nil { + return errors.Wrap(err, "can't ping database") + } + return nil +} + +func (d *DB) PingRoutine(interval time.Duration) { + ticker := time.NewTicker(interval) + done := make(chan bool) + for { + select { + case <-done: + return + case t := <-ticker.C: + if err := testConnection(d.Conn); err != nil { + log.Error("failed to ping postrges db", "error", err, "ping_at", t) + // reconnect + if err := closeConn(d.Conn); err != nil { + log.Error("failed to close db connection", "error", err, "ping_at", t) + } + d.Conn, err = openDBConnection(d.URI, dbDriver) + if err != nil { + log.Error("failed to reconnect", "error", err, "ping_at", t) + } + } + } + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..d0ccdff --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "crypto/hmac" + "crypto/sha256" + "demoon/internal/models" + "demoon/pkg/utils" + "encoding/base64" + "encoding/json" + "html/template" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +func abortWithError(w http.ResponseWriter, msg string) { + tmpl := template.Must(template.ParseGlob("components/*.html")) + tmpl.ExecuteTemplate(w, "error", msg) +} + +func (h *Handlers) HandleSignup(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + username := r.PostFormValue("username") + if username == "" { + msg := "username not provided" + h.log.Error(msg) + abortWithError(w, msg) + return + } + password := r.PostFormValue("password") + if password == "" { + msg := "password not provided" + h.log.Error(msg) + abortWithError(w, msg) + return + } + // make sure username does not exists + cleanName := utils.RemoveSpacesFromStr(username) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 8) + // create user in db + now := time.Now() + nextMidnight := time.Date(now.Year(), now.Month(), now.Day(), + 0, 0, 0, 0, time.UTC).Add(time.Hour * 24) + newUser := &models.UserScore{ + Username: cleanName, Password: string(hashedPassword), + BurnTime: nextMidnight, CreatedAt: now, + } + // login user + cookie, err := h.makeCookie(cleanName, r.RemoteAddr) + if err != nil { + h.log.Error("failed to login", "error", err) + abortWithError(w, err.Error()) + return + } + http.SetCookie(w, cookie) + // http.Redirect(w, r, "/", 302) + tmpl, err := template.ParseGlob("components/*.html") + if err != nil { + abortWithError(w, err.Error()) + return + } + tmpl.ExecuteTemplate(w, "main", newUser) +} + +func (h *Handlers) HandleLogin(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + username := r.PostFormValue("username") + if username == "" { + msg := "username not provided" + h.log.Error(msg) + abortWithError(w, msg) + return + } + password := r.PostFormValue("password") + if password == "" { + msg := "password not provided" + h.log.Error(msg) + abortWithError(w, msg) + return + } + cleanName := utils.RemoveSpacesFromStr(username) + tmpl, err := template.ParseGlob("components/*.html") + if err != nil { + abortWithError(w, err.Error()) + return + } + cookie, err := h.makeCookie(cleanName, r.RemoteAddr) + if err != nil { + h.log.Error("failed to login", "error", err) + abortWithError(w, err.Error()) + return + } + http.SetCookie(w, cookie) + tmpl.ExecuteTemplate(w, "main", nil) +} + +func (h *Handlers) makeCookie(username string, remote string) (*http.Cookie, error) { + // secret + // Create a new random session token + // sessionToken := xid.New().String() + sessionToken := "token" + expiresAt := time.Now().Add(time.Duration(h.cfg.SessionLifetime) * time.Second) + // Set the token in the session map, along with the session information + session := &models.Session{ + Username: username, + Expiry: expiresAt, + } + cookieName := "session_token" + // hmac to protect cookies + hm := hmac.New(sha256.New, []byte(h.cfg.CookieSecret)) + hm.Write([]byte(cookieName)) + hm.Write([]byte(sessionToken)) + signature := hm.Sum(nil) + // b64 enc to avoid non-ascii + cookieValue := base64.URLEncoding.EncodeToString([]byte( + string(signature) + sessionToken)) + cookie := &http.Cookie{ + Name: cookieName, + Value: cookieValue, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + Domain: h.cfg.ServerConfig.Host, + } + h.log.Info("check remote addr for cookie set", + "remote", remote, "session", session) + if strings.Contains(remote, "192.168.0") { + // no idea what is going on + cookie.Domain = "192.168.0.101" + } + // set ctx? + // set user in session + if err := h.cacheSetSession(sessionToken, session); err != nil { + return nil, err + } + return cookie, nil +} + +func (h *Handlers) cacheGetSession(key string) (*models.Session, error) { + userSessionB, err := h.mc.Get(key) + if err != nil { + return nil, err + } + var us *models.Session + if err := json.Unmarshal(userSessionB, &us); err != nil { + return nil, err + } + return us, nil +} + +func (h *Handlers) cacheSetSession(key string, session *models.Session) error { + sesb, err := json.Marshal(session) + if err != nil { + return err + } + h.mc.Set(key, sesb) + // expire in 10 min + h.mc.Expire(key, 10*60) + return nil +} diff --git a/internal/handlers/main.go b/internal/handlers/main.go new file mode 100644 index 0000000..960b26d --- /dev/null +++ b/internal/handlers/main.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "demoon/config" + "demoon/internal/database/repos" + "demoon/internal/models" + "demoon/pkg/cache" + "html/template" + "log/slog" + "net/http" + "os" +) + +var defUS = models.UserScore{} + +// Handlers structure +type Handlers struct { + cfg config.Config + log *slog.Logger + repo repos.FullRepo + mc cache.Cache +} + +// NewHandlers constructor +func NewHandlers( + cfg config.Config, l *slog.Logger, repo repos.FullRepo, +) *Handlers { + if l == nil { + l = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + } + h := &Handlers{ + cfg: cfg, + log: l, + repo: repo, + mc: cache.MemCache, + } + return h +} + +func (h *Handlers) Ping(w http.ResponseWriter, r *http.Request) { + h.log.Info("got ping request") + w.Write([]byte("pong")) +} + +func (h *Handlers) MainPage(w http.ResponseWriter, r *http.Request) { + tmpl, err := template.ParseGlob("components/*.html") + if err != nil { + abortWithError(w, err.Error()) + return + } + // get recommendations + usernameRaw := r.Context().Value("username") + h.log.Info("got mainpage request", "username", usernameRaw) + if usernameRaw == nil { + tmpl.ExecuteTemplate(w, "main", defUS) + return + } + username := usernameRaw.(string) + if username == "" { + tmpl.ExecuteTemplate(w, "main", defUS) + return + } + tmpl.ExecuteTemplate(w, "main", nil) +} diff --git a/internal/handlers/middleware.go b/internal/handlers/middleware.go new file mode 100644 index 0000000..8b871a2 --- /dev/null +++ b/internal/handlers/middleware.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" +) + +func (h *Handlers) GetSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookieName := "session_token" + sessionCookie, err := r.Cookie(cookieName) + if err != nil { + msg := "auth failed; failed to get session token from cookies" + h.log.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + cookieValueB, err := base64.URLEncoding. + DecodeString(sessionCookie.Value) + if err != nil { + msg := "auth failed; failed to decode b64 cookie" + h.log.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + cookieValue := string(cookieValueB) + if len(cookieValue) < sha256.Size { + h.log.Warn("small cookie", "size", len(cookieValue)) + next.ServeHTTP(w, r) + return + } + // Split apart the signature and original cookie value. + signature := cookieValue[:sha256.Size] + sessionToken := cookieValue[sha256.Size:] + //verify signature + mac := hmac.New(sha256.New, []byte(h.cfg.CookieSecret)) + mac.Write([]byte(cookieName)) + mac.Write([]byte(sessionToken)) + expectedSignature := mac.Sum(nil) + if !hmac.Equal([]byte(signature), expectedSignature) { + h.log.Debug("cookie with an invalid sign") + next.ServeHTTP(w, r) + return + } + userSession, err := h.cacheGetSession(sessionToken) + if err != nil { + msg := "auth failed; session does not exists" + err = errors.New(msg) + h.log.Debug(msg, "error", err) + next.ServeHTTP(w, r) + return + } + if userSession.IsExpired() { + h.mc.RemoveKey(sessionToken) + msg := "session is expired" + h.log.Debug(msg, "error", err, "token", sessionToken) + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), + "username", userSession.Username) + if err := h.cacheSetSession(sessionToken, + userSession); err != nil { + msg := "failed to marshal user session" + h.log.Warn(msg, "error", err) + next.ServeHTTP(w, r) + return + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/models/auth.go b/internal/models/auth.go new file mode 100644 index 0000000..9964cd5 --- /dev/null +++ b/internal/models/auth.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" +) + +// each session contains the username of the user and the time at which it expires +type Session struct { + Username string + Expiry time.Time +} + +// we'll use this method later to determine if the session has expired +func (s Session) IsExpired() bool { + return s.Expiry.Before(time.Now()) +} + +func ListUsernames(ss map[string]*Session) []string { + resp := make([]string, 0, len(ss)) + for _, s := range ss { + resp = append(resp, s.Username) + } + return resp +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..5bc120b --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,45 @@ +package models + +import "time" + +type ActionType string + +const ( + ActionTypePlus ActionType = "ActionTypePlus" + ActionTypeMinus ActionType = "ActionTypeMinus" +) + +type ( + UserScore struct { + ID uint32 `db:"id"` + Username string `db:"username"` + Password string `db:"password"` + Actions []Action + Recommendations []Action + BurnTime time.Time `db:"burn_time"` + Score int8 `db:"score"` + CreatedAt time.Time `db:"created_at"` + } + Action struct { + ID uint32 `db:"id"` + Name string `db:"name"` + Magnitude uint8 `db:"magnitude"` + Repeatable bool `db:"repeatable"` + Type ActionType `db:"type"` + Done bool `db:"done"` + Username string `db:"username"` + CreatedAt time.Time `db:"created_at"` + } +) + +func (us *UserScore) UpdateScore(act *Action) { + switch act.Type { + case ActionTypePlus: + us.Score += int8(act.Magnitude) + if !act.Repeatable { + act.Done = true + } + case ActionTypeMinus: + us.Score -= int8(act.Magnitude) + } +} diff --git a/internal/server/main.go b/internal/server/main.go new file mode 100644 index 0000000..2c3c8d6 --- /dev/null +++ b/internal/server/main.go @@ -0,0 +1,59 @@ +package server + +import ( + "demoon/config" + "demoon/internal/database/repos" + "demoon/internal/handlers" + + "context" + "log/slog" + "os" + "os/signal" + "syscall" +) + +// Server interface +type Server interface { + Listen() +} + +type server struct { + config config.Config + actions *handlers.Handlers + ctx context.Context + close context.CancelFunc +} + +func (srv *server) stopOnSignal(close context.CancelFunc) { + // listen for termination signals + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, os.Interrupt, syscall.SIGINT) + signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) + sig := <-sigc + + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + log.Info("Shutting down services", + "section", "server", + "app_event", "terminate", + "signal", sig.String()) + close() + os.Exit(0) +} + +func NewServer(cfg config.Config, log *slog.Logger, repo repos.FullRepo) Server { + ctx, close := context.WithCancel(context.Background()) + actions := handlers.NewHandlers(cfg, log, repo) + return &server{ + config: cfg, + actions: actions, + ctx: ctx, + close: close, + } +} + +// Listen for new events that affect the market and process them +func (srv *server) Listen() { + // start the http server + go srv.ListenToRequests() + srv.stopOnSignal(srv.close) +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000..989766c --- /dev/null +++ b/internal/server/router.go @@ -0,0 +1,31 @@ +package server + +import ( + "fmt" + "net/http" + "time" +) + +func (srv *server) ListenToRequests() { + h := srv.actions + mux := http.NewServeMux() + server := &http.Server{ + Addr: fmt.Sprintf("localhost:%s", srv.config.ServerConfig.Port), + Handler: h.GetSession(mux), + ReadTimeout: time.Second * 5, + WriteTimeout: time.Second * 5, + } + + fs := http.FileServer(http.Dir("assets/")) + mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs)) + + mux.HandleFunc("GET /ping", h.Ping) + mux.HandleFunc("GET /", h.MainPage) + mux.HandleFunc("POST /login", h.HandleLogin) + mux.HandleFunc("POST /signup", h.HandleSignup) + + // ====== elements ====== + + fmt.Println("Listening", "addr", server.Addr) + server.ListenAndServe() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1cff4bc --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "runtime" + "demoon/cmd" +) + +func init() { + runtime.GOMAXPROCS(runtime.NumCPU()) +} + +func main() { + cmd.Execute() +} diff --git a/pkg/cache/interface.go b/pkg/cache/interface.go new file mode 100644 index 0000000..606f50f --- /dev/null +++ b/pkg/cache/interface.go @@ -0,0 +1,9 @@ +package cache + +type Cache interface { + Get(key string) ([]byte, error) + Set(key string, value []byte) + Expire(key string, exp int64) + GetAll() (resp map[string][]byte) + RemoveKey(key string) +} diff --git a/pkg/cache/main.go b/pkg/cache/main.go new file mode 100644 index 0000000..2aac109 --- /dev/null +++ b/pkg/cache/main.go @@ -0,0 +1,146 @@ +package cache + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log/slog" + "os" + "sync" + "time" +) + +const storeFileName = "store.json" + +// var MemCache Cache +var ( + MemCache *MemoryCache + log = slog.New(slog.NewJSONHandler(os.Stdout, nil)) +) + +func readJSON(fileName string) (map[string][]byte, error) { + data := make(map[string][]byte) + file, err := os.Open(fileName) + if err != nil { + return data, err + } + defer file.Close() + decoder := json.NewDecoder(file) + if err := decoder.Decode(&data); err != nil { + return data, err + } + return data, nil +} + +func init() { + data, err := readJSON(storeFileName) + if err != nil { + log.Warn("failed to load store from file", "error", err) + } + MemCache = &MemoryCache{ + data: data, + timeMap: make(map[string]time.Time), + lock: &sync.RWMutex{}, + } + MemCache.StartExpiryRoutine(time.Hour * 24 * 30) + MemCache.StartBackupRoutine(time.Minute) +} + +type MemoryCache struct { + data map[string][]byte + timeMap map[string]time.Time + lock *sync.RWMutex +} + +// Get a value by key from the cache +func (mc *MemoryCache) Get(key string) (value []byte, err error) { + var ok bool + mc.lock.RLock() + if value, ok = mc.data[key]; !ok { + err = fmt.Errorf("not found data in mc for the key: %v", key) + } + mc.lock.RUnlock() + return value, err +} + +// Update a single value in the cache +func (mc *MemoryCache) Set(key string, value []byte) { + // no async writing + mc.lock.Lock() + mc.data[key] = value + mc.lock.Unlock() +} + +func (mc *MemoryCache) Expire(key string, exp int64) { + mc.lock.RLock() + mc.timeMap[key] = time.Now().Add(time.Duration(exp) * time.Second) + mc.lock.RUnlock() +} + +func (mc *MemoryCache) GetAll() (resp map[string][]byte) { + resp = make(map[string][]byte) + mc.lock.RLock() + for k, v := range mc.data { + resp[k] = v + } + mc.lock.RUnlock() + return +} + +func (mc *MemoryCache) GetAllTime() (resp map[string]time.Time) { + resp = make(map[string]time.Time) + mc.lock.RLock() + for k, v := range mc.timeMap { + resp[k] = v + } + mc.lock.RUnlock() + return +} + +func (mc *MemoryCache) RemoveKey(key string) { + mc.lock.RLock() + delete(mc.data, key) + delete(mc.timeMap, key) + mc.lock.RUnlock() +} + +func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) { + ticker := time.NewTicker(n) + go func() { + for { + <-ticker.C + // get all + timeData := mc.GetAllTime() + // check time + currentTS := time.Now() + for k, ts := range timeData { + if ts.Before(currentTS) { + // delete exp keys + mc.RemoveKey(k) + log.Info("remove by expiry", "key", k) + } + } + } + }() +} + +func (mc *MemoryCache) StartBackupRoutine(n time.Duration) { + ticker := time.NewTicker(n) + go func() { + for { + <-ticker.C + // get all + data := mc.GetAll() + jsonString, err := json.Marshal(data) + if err != nil { + log.Warn("failed to marshal kv store", "error", err) + continue + } + err = ioutil.WriteFile(storeFileName, jsonString, os.ModePerm) + if err != nil { + log.Warn("failed to write to json file", "error", err) + continue + } + } + }() +} diff --git a/pkg/utils/main.go b/pkg/utils/main.go new file mode 100644 index 0000000..f49313e --- /dev/null +++ b/pkg/utils/main.go @@ -0,0 +1,24 @@ +package utils + +import ( + "strings" + "unicode" +) + +func RemoveSpacesFromStr(origin string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, origin) +} + +func StrInSlice(key string, sl []string) bool { + for _, i := range sl { + if key == i { + return true + } + } + return false +} -- cgit v1.2.3