From ce17b205d217bc229b8c179eb352736d16d89fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?To=CF=80Senpai?= Date: Sat, 4 Mar 2023 03:12:31 +0100 Subject: [PATCH] implement jwt tokens for sharing (#6) --- README.md | 8 +-- assets/script.js | 122 +++++++++++++++++++++++--------------- assets/style.css | 47 ++++++--------- go.mod | 2 + go.sum | 13 ++++ gobin/config.go | 3 +- gobin/database.go | 37 ++++++------ gobin/jwt.go | 97 ++++++++++++++++++++++++++++++ gobin/routes.go | 110 ++++++++++++++++++++++++++-------- gobin/server.go | 6 +- main.go | 13 +++- sql/migration.sql | 2 + sql/schema.sql | 1 - templates/document.gohtml | 25 ++++---- 14 files changed, 343 insertions(+), 143 deletions(-) create mode 100644 gobin/jwt.go diff --git a/README.md b/README.md index 30434e1..b8f0a70 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ A successful request will return a `200 OK` response with a JSON body containing { "key": "hocwr6i6", "version": 1, - "update_token": "kiczgez33j7qkvqdg9f7ksrd8jk88wba" + "token": "kiczgez33j7qkvqdg9f7ksrd8jk88wba" } ``` @@ -271,7 +271,7 @@ The response will be a `200 OK` with the document content as `application/json` ### Update a document -To update a paste you have to send a `PATCH` request to `/documents/{key}` with the `content` as `plain/text` body and the `update_token` as `Authorization` header. +To update a paste you have to send a `PATCH` request to `/documents/{key}` with the `content` as `plain/text` body and the `token` as `Authorization` header. > **Note** > You can also specify the code language with the `Language` header. @@ -303,7 +303,7 @@ A successful request will return a `200 OK` response with a JSON body containing ### Delete a document -To delete a document you have to send a `DELETE` request to `/documents/{key}` with the `update_token` as `Authorization` header. +To delete a document you have to send a `DELETE` request to `/documents/{key}` with the `token` as `Authorization` header. A successful request will return a `204 No Content` response with an empty body. @@ -311,7 +311,7 @@ A successful request will return a `204 No Content` response with an empty body. ### Delete a document version -To delete a document version you have to send a `DELETE` request to `/documents/{key}/versions/{version}` with the `update_token` as `Authorization` header. +To delete a document version you have to send a `DELETE` request to `/documents/{key}/versions/{version}` with the `token` as `Authorization` header. A successful request will return a `204 No Content` response with an empty body. diff --git a/assets/script.js b/assets/script.js index 1b5eea0..d49989a 100644 --- a/assets/script.js +++ b/assets/script.js @@ -17,7 +17,7 @@ document.addEventListener("DOMContentLoaded", async () => { const version = window.location.hash === "" ? 0 : parseInt(window.location.hash.slice(1)); const params = new URLSearchParams(window.location.search); if (params.has("token")) { - setUpdateToken(key, params.get("token")); + setToken(key, params.get("token")); } document.querySelector("#nav-btn").checked = false; @@ -89,8 +89,8 @@ document.querySelector("#code-edit").addEventListener("keyup", (event) => { document.querySelector("#edit").addEventListener("click", async () => { if (document.querySelector("#edit").disabled) return; - const {key, version, content, language} = getState(); - const {newState, url} = createState(getUpdateToken(key) === "" ? "" : key, 0, "edit", content, language); + const {key, content, language} = getState(); + const {newState, url} = createState(hasPermission(getToken(key), "write") ? key : "", 0, "edit", content, language); updateCode(newState); updatePage(newState); window.history.pushState(newState, "", url); @@ -100,17 +100,17 @@ document.querySelector("#save").addEventListener("click", async () => { if (document.querySelector("#save").disabled) return; const {key, mode, content, language} = getState() if (mode !== "edit") return; - const updateToken = getUpdateToken(key); + const token = getToken(key); const saveButton = document.querySelector("#save"); saveButton.classList.add("loading"); let response; - if (key && updateToken) { + if (key && token) { response = await fetch(`/documents/${key}`, { method: "PATCH", body: content, headers: { - Authorization: updateToken, + Authorization: `Bearer ${token}`, Language: language } }); @@ -132,9 +132,10 @@ document.querySelector("#save").addEventListener("click", async () => { return; } - const {newState, url} = createState(body.key, body.version, "view", content, language); - setUpdateToken(body.key, body.update_token); - + const {newState, url} = createState(body.key, 0, "view", content, language); + if (body.token) { + setToken(body.key, body.token); + } const inputElement = document.createElement("input") const labelElement = document.createElement("label") @@ -166,10 +167,8 @@ document.querySelector("#delete").addEventListener("click", async () => { if (document.querySelector("#delete").disabled) return; const {key} = getState(); - const updateToken = getUpdateToken(key); - if (updateToken === "") { - return; - } + const token = getToken(key); + if (!token) return; const deleteConfirm = window.confirm("Are you sure you want to delete this document? This action cannot be undone.") if (!deleteConfirm) return; @@ -179,7 +178,7 @@ document.querySelector("#delete").addEventListener("click", async () => { let response = await fetch(`/documents/${key}`, { method: "DELETE", headers: { - Authorization: updateToken + Authorization: `Bearer ${token}` } }); deleteButton.classList.remove("loading"); @@ -190,7 +189,7 @@ document.querySelector("#delete").addEventListener("click", async () => { console.error("error deleting document:", response); return; } - deleteUpdateToken(); + deleteToken(); const {newState, url} = createState("", 0, "edit", "", ""); updateCode(newState); updatePage(newState); @@ -217,14 +216,16 @@ document.querySelector("#share").addEventListener("click", async () => { if (document.querySelector("#share").disabled) return; const {key} = getState(); - const updateToken = getUpdateToken(key); - if (updateToken === "") { + const token = getToken(key); + if (!hasPermission(token, "share")) { await navigator.clipboard.writeText(window.location.href); return; } - document.querySelector("#share-permissions").checked = false; - document.querySelector("#share-url").value = window.location.href; + document.querySelector("#share-permissions-write").checked = false; + document.querySelector("#share-permissions-delete").checked = false; + document.querySelector("#share-permissions-share").checked = false; + document.querySelector("#share-dialog").showModal(); }); @@ -232,28 +233,46 @@ document.querySelector("#share-dialog-close").addEventListener("click", () => { document.querySelector("#share-dialog").close(); }); -document.querySelector("#share-permissions").addEventListener("change", (event) => { - const {key} = getState(); - const updateToken = getUpdateToken(key); - if (updateToken === "") { - return; +document.querySelector("#share-copy").addEventListener("click", async () => { + const permissions = []; + if (document.querySelector("#share-permissions-write").checked) { + permissions.push("write"); + } + if (document.querySelector("#share-permissions-delete").checked) { + permissions.push("delete"); + } + if (document.querySelector("#share-permissions-share").checked) { + permissions.push("share"); } - const shareUrl = document.querySelector("#share-url"); - if (event.target.checked) { - shareUrl.value = `${window.location.href}?token=${updateToken}`; + if (permissions.length === 0) { + await navigator.clipboard.writeText(window.location.href); + document.querySelector("#share-dialog").close(); return; } - shareUrl.value = window.location.href; -}); -document.querySelector("#share-url").addEventListener("click", () => { - document.querySelector("#share-url").select(); -}); + const {key} = getState(); + const token = getToken(key); -document.querySelector("#share-copy").addEventListener("click", async () => { - const shareUrl = document.querySelector("#share-url"); - await navigator.clipboard.writeText(shareUrl.value); + const response = await fetch(`/documents/${key}/share`, { + method: "POST", + body: JSON.stringify({permissions: permissions}), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + } + }); + + if (!response.ok) { + const body = await response.json(); + showErrorPopup(body.message || response.statusText) + console.error("error sharing document:", response); + return; + } + + const body = await response.json() + const shareUrl = window.location.href + "?token=" + body.token; + await navigator.clipboard.writeText(shareUrl); document.querySelector("#share-dialog").close(); }); @@ -272,9 +291,9 @@ document.querySelector("#style").addEventListener("change", (event) => { document.querySelector("#versions").addEventListener("click", async (event) => { if (event.target && event.target.matches("input[type='radio']")) { const {key, version} = getState(); - let newVersion = event.target.value; - if (event.target.parentElement.children.item(0).value === newVersion) { - newVersion = "" + let newVersion = parseInt(event.target.value); + if (event.target.parentElement.children.item(0).value === `${newVersion}`) { + newVersion = 0; } if (newVersion === version) return; const {newState, url} = await fetchVersion(key, newVersion) @@ -314,26 +333,26 @@ function createState(key, version, mode, content, language) { return {newState: {key, version, mode, content: content.trim(), language}, url: `/${key}${version ? `#${version}` : ""}`}; } -function getUpdateToken(key) { +function getToken(key) { const documents = localStorage.getItem("documents") if (!documents) return "" - const updateToken = JSON.parse(documents)[key] - if (!updateToken) return "" + const token = JSON.parse(documents)[key] + if (!token) return "" - return updateToken + return token } -function setUpdateToken(key, updateToken) { +function setToken(key, token) { let documents = localStorage.getItem("documents") if (!documents) { documents = "{}" } const parsedDocuments = JSON.parse(documents) - parsedDocuments[key] = updateToken + parsedDocuments[key] = token localStorage.setItem("documents", JSON.stringify(parsedDocuments)) } -function deleteUpdateToken() { +function deleteToken() { const {key} = getState(); const documents = localStorage.getItem("documents"); if (!documents) return; @@ -342,6 +361,13 @@ function deleteUpdateToken() { localStorage.setItem("documents", JSON.stringify(parsedDocuments)); } +function hasPermission(token, permission) { + if (!token) return false; + const tokenSplit = token.split(".") + if (tokenSplit.length !== 3) return false; + return JSON.parse(atob(tokenSplit[1])).permissions.includes(permission); +} + function updateCode(state) { const {mode, content} = state; @@ -365,7 +391,7 @@ function updateCode(state) { function updatePage(state) { const {key, mode, content} = state; - const updateToken = getUpdateToken(key); + const token = getToken(key); // update page title if (key) { document.title = `gobin - ${key}`; @@ -386,9 +412,7 @@ function updatePage(state) { saveButton.style.display = "none"; editButton.disabled = false; editButton.style.display = "block"; - if (updateToken) { - deleteButton.disabled = false; - } + deleteButton.disabled = !hasPermission(token, "delete"); copyButton.disabled = false; rawButton.disabled = false; shareButton.disabled = false; diff --git a/assets/style.css b/assets/style.css index 72f83f3..8d78e80 100644 --- a/assets/style.css +++ b/assets/style.css @@ -98,7 +98,6 @@ html { border: none; border-radius: 1rem; padding: 1rem; - background-color: var(--bg-secondary); } @@ -106,13 +105,13 @@ dialog::backdrop { background-color: rgba(0, 0, 0, 0.7); } -#share-dialog div { +.share-dialog-header { display: flex; justify-content: space-between; align-items: center; } -#share-dialog div h2 { +.share-dialog-header h2 { font-size: 1.5rem; font-weight: bold; margin: 0; @@ -122,37 +121,19 @@ dialog::backdrop { background-image: var(--close); } -#share-url-label { +.share-dialog-main { display: flex; - justify-content: space-between; - align-items: center; gap: 1rem; + align-items: flex-end; + justify-content: space-between; } -#share-url-label input { - width: 100%; - padding: 0.5rem; - border: none; - border-radius: 1rem; - background-color: var(--bg-primary); - color: var(--text-primary); - font-family: monospace; - font-size: 1rem; -} - -#share-url-label button { - padding: 0.5rem; - border: none; - border-radius: 1rem; - background-color: var(--bg-primary); - color: var(--text-primary); - font-family: monospace; - font-size: 1rem; - cursor: pointer; -} - -#share-url-label button:hover { - opacity: 0.7; +.share-dialog-permissions { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + width: fit-content; + align-items: center; } body { @@ -271,6 +252,12 @@ nav { background-position: center; background-size: 1rem; cursor: pointer; + color: var(--text-primary); +} + +#share-copy { + width: fit-content; + padding: 0.5rem; } .button:hover, button:hover { diff --git a/go.mod b/go.mod index 173b43a..43e1eb8 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.18 require ( github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/httprate v0.7.1 + github.com/go-jose/go-jose/v3 v3.0.0 github.com/jackc/pgx/v5 v5.2.0 github.com/jmoiron/sqlx v1.3.5 + golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 modernc.org/sqlite v1.20.4 ) diff --git a/go.sum b/go.sum index f6ddd8d..b875a36 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,11 @@ github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/httprate v0.7.1 h1:d5kXARdms2PREQfU4pHvq44S6hJ1hPu4OXLeBKmCKWs= github.com/go-chi/httprate v0.7.1/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= +github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= +github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= 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.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -38,20 +41,30 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9 h1:frX3nT9RkKybPnjyI+yvZh6ZucTZatCCEm9D47sZ2zo= +golang.org/x/exp v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/gobin/config.go b/gobin/config.go index cb43b20..4ff66ab 100644 --- a/gobin/config.go +++ b/gobin/config.go @@ -15,10 +15,11 @@ type Config struct { Database DatabaseConfig `json:"database"` MaxDocumentSize int `json:"max_document_size"` RateLimit *RateLimitConfig `json:"rate_limit"` + JWTSecret string `json:"jwt_secret"` } func (c Config) String() string { - return fmt.Sprintf("\n DevMode: %t\n Debug: %t\n ListenAddr: %s\n Database: %s\n MaxDocumentSize: %d\n Rate Limit: %s\n", c.DevMode, c.Debug, c.ListenAddr, c.Database, c.MaxDocumentSize, c.RateLimit) + return fmt.Sprintf("\n DevMode: %t\n Debug: %t\n ListenAddr: %s\n Database: %s\n MaxDocumentSize: %d\n Rate Limit: %s\n JWTSecret: %s\n", c.DevMode, c.Debug, c.ListenAddr, c.Database, c.MaxDocumentSize, c.RateLimit, strings.Repeat("*", len(c.JWTSecret))) } type DatabaseConfig struct { diff --git a/gobin/database.go b/gobin/database.go index 79940db..47956b0 100644 --- a/gobin/database.go +++ b/gobin/database.go @@ -76,11 +76,10 @@ func NewDB(ctx context.Context, cfg DatabaseConfig, schema string) (*DB, error) } type Document struct { - ID string `db:"id"` - Version int64 `db:"version"` - Content string `db:"content"` - Language string `db:"language"` - UpdateToken string `db:"update_token"` + ID string `db:"id"` + Version int64 `db:"version"` + Content string `db:"content"` + Language string `db:"language"` } type DB struct { @@ -141,13 +140,12 @@ func (d *DB) createDocument(ctx context.Context, content string, language string } now := time.Now().Unix() doc := Document{ - ID: randomString(8), - Content: content, - Language: language, - UpdateToken: randomString(32), - Version: now, + ID: randomString(8), + Content: content, + Language: language, + Version: now, } - _, err := d.dbx.NamedExecContext(ctx, "INSERT INTO documents (id, version, content, language, update_token) VALUES (:id, :version, :content, :language, :update_token)", doc) + _, err := d.dbx.NamedExecContext(ctx, "INSERT INTO documents (id, version, content, language) VALUES (:id, :version, :content, :language)", doc) if err != nil { var ( @@ -164,15 +162,14 @@ func (d *DB) createDocument(ctx context.Context, content string, language string return doc, err } -func (d *DB) UpdateDocument(ctx context.Context, documentID string, updateToken string, content string, language string) (Document, error) { +func (d *DB) UpdateDocument(ctx context.Context, documentID string, content string, language string) (Document, error) { doc := Document{ - ID: documentID, - Version: time.Now().Unix(), - Content: content, - Language: language, - UpdateToken: updateToken, + ID: documentID, + Version: time.Now().Unix(), + Content: content, + Language: language, } - res, err := d.dbx.NamedExecContext(ctx, "INSERT INTO documents (id, version, content, language, update_token) VALUES (:id, :version, :content, :language, :update_token)", doc) + res, err := d.dbx.NamedExecContext(ctx, "INSERT INTO documents (id, version, content, language) VALUES (:id, :version, :content, :language)", doc) if err != nil { return Document{}, err } @@ -187,8 +184,8 @@ func (d *DB) UpdateDocument(ctx context.Context, documentID string, updateToken return doc, nil } -func (d *DB) DeleteDocument(ctx context.Context, documentID string, updateToken string) error { - res, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE id = $1 AND update_token = $2", documentID, updateToken) +func (d *DB) DeleteDocument(ctx context.Context, documentID string) error { + res, err := d.dbx.ExecContext(ctx, "DELETE FROM documents WHERE id = $1", documentID) if err != nil { return err } diff --git a/gobin/jwt.go b/gobin/jwt.go new file mode 100644 index 0000000..af5c7ee --- /dev/null +++ b/gobin/jwt.go @@ -0,0 +1,97 @@ +package gobin + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-jose/go-jose/v3/jwt" +) + +var ( + ErrNoPermissions = errors.New("no permissions provided") + ErrUnknownPermission = func(p Permission) error { + return fmt.Errorf("unknown permission: %s", p) + } + ErrPermissionDenied = func(p Permission) error { + return fmt.Errorf("permission denied: %s", p) + } +) + +type Permission string + +const ( + PermissionWrite Permission = "write" + PermissionDelete Permission = "delete" + PermissionShare Permission = "share" +) + +func (p Permission) IsValid() bool { + return p == PermissionWrite || p == PermissionDelete || p == PermissionShare +} + +type Claims struct { + jwt.Claims + Permissions []Permission `json:"permissions"` +} + +type claimsKey struct{} + +var ClaimsKey = claimsKey{} + +func (s *Server) JWTMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenString := TokenFromHeader(r) + + var claims Claims + if tokenString == "" { + documentID := chi.URLParam(r, "documentID") + claims = newClaims(documentID, nil) + } else { + token, err := jwt.ParseSigned(tokenString) + if err != nil { + s.error(w, r, err, http.StatusUnauthorized) + return + } + + if err = token.Claims([]byte(s.cfg.JWTSecret), &claims); err != nil { + s.error(w, r, err, http.StatusUnauthorized) + return + } + } + + ctx := context.WithValue(r.Context(), ClaimsKey, &claims) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) GetClaims(r *http.Request) *Claims { + return r.Context().Value(ClaimsKey).(*Claims) +} + +func (s *Server) NewToken(documentID string, permissions []Permission) (string, error) { + claims := newClaims(documentID, permissions) + return jwt.Signed(s.signer).Claims(claims).CompactSerialize() +} + +func newClaims(documentID string, permissions []Permission) Claims { + return Claims{ + Claims: jwt.Claims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: documentID, + }, + Permissions: permissions, + } +} + +func TokenFromHeader(r *http.Request) string { + bearer := r.Header.Get("Authorization") + if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" { + return bearer[7:] + } + return "" +} diff --git a/gobin/routes.go b/gobin/routes.go index 6a5c988..7697902 100644 --- a/gobin/routes.go +++ b/gobin/routes.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" + "golang.org/x/exp/slices" ) var ( @@ -51,7 +52,13 @@ type ( VersionTime string `json:"version_time,omitempty"` Data string `json:"data,omitempty"` Language string `json:"language"` - UpdateToken string `json:"update_token,omitempty"` + Token string `json:"token,omitempty"` + } + ShareRequest struct { + Permissions []Permission `json:"permissions"` + } + ShareResponse struct { + Token string `json:"token"` } ErrorResponse struct { Message string `json:"message"` @@ -76,6 +83,7 @@ func (s *Server) Routes() http.Handler { )) r.Use(middleware.Recoverer) r.Use(middleware.Heartbeat("/ping")) + r.Use(s.JWTMiddleware) if s.cfg.RateLimit != nil && s.cfg.RateLimit.Requests > 0 && s.cfg.RateLimit.Duration > 0 { rateLimiter := httprate.NewRateLimiter( @@ -116,6 +124,7 @@ func (s *Server) Routes() http.Handler { r.Get("/", s.GetDocument) r.Patch("/", s.PatchDocument) r.Delete("/", s.DeleteDocument) + r.Post("/share", s.PostDocumentShare) r.Route("/versions", func(r chi.Router) { r.Get("/", s.DocumentVersions) r.Route("/{version}", func(r chi.Router) { @@ -165,7 +174,7 @@ func (s *Server) GetDocumentVersion(w http.ResponseWriter, r *http.Request) { document, err := s.db.GetDocumentVersion(r.Context(), documentID, version) if err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return } s.log(r, "get document version", err) @@ -189,7 +198,7 @@ func (s *Server) DeleteDocumentVersion(w http.ResponseWriter, r *http.Request) { if err := s.db.DeleteDocumentByVersion(r.Context(), version, documentID); err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return } s.log(r, "delete document version", err) @@ -207,7 +216,7 @@ func (s *Server) GetRawDocumentVersion(w http.ResponseWriter, r *http.Request) { document, err := s.db.GetDocumentVersion(r.Context(), documentID, version) if err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return } s.log(r, "get document version", err) @@ -228,13 +237,13 @@ func parseDocumentVersion(r *http.Request, s *Server, w http.ResponseWriter) (st documentID := chi.URLParam(r, "documentID") version := chi.URLParam(r, "version") if documentID == "" || version == "" { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return "", -1 } int64Version, err := strconv.ParseInt(version, 10, 64) if err != nil { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return "", -1 } return documentID, int64Version @@ -371,13 +380,20 @@ func (s *Server) PostDocument(w http.ResponseWriter, r *http.Request) { return } + token, err := s.NewToken(document.ID, []Permission{PermissionWrite, PermissionDelete, PermissionShare}) + if err != nil { + s.log(r, "creating jwt token", err) + s.error(w, r, err, http.StatusInternalServerError) + return + } + versionLabel, versionTime := formatVersion(time.Now(), document.Version) s.ok(w, r, DocumentResponse{ Key: document.ID, Version: document.Version, VersionLabel: versionLabel, VersionTime: versionTime, - UpdateToken: document.UpdateToken, + Token: token, }) } @@ -385,8 +401,9 @@ func (s *Server) PatchDocument(w http.ResponseWriter, r *http.Request) { documentID := chi.URLParam(r, "documentID") language := r.Header.Get("Language") - updateToken := s.getUpdateToken(w, r) - if updateToken == "" { + claims := s.GetClaims(r) + if claims.Subject != documentID || !slices.Contains(claims.Permissions, PermissionWrite) { + s.documentNotFound(w, r) return } @@ -399,10 +416,10 @@ func (s *Server) PatchDocument(w http.ResponseWriter, r *http.Request) { return } - document, err := s.db.UpdateDocument(r.Context(), documentID, updateToken, content, language) + document, err := s.db.UpdateDocument(r.Context(), documentID, content, language) if err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return } s.error(w, r, err, http.StatusInternalServerError) @@ -420,11 +437,16 @@ func (s *Server) PatchDocument(w http.ResponseWriter, r *http.Request) { func (s *Server) DeleteDocument(w http.ResponseWriter, r *http.Request) { documentID := chi.URLParam(r, "documentID") - updateToken := r.Header.Get("Authorization") - if err := s.db.DeleteDocument(r.Context(), documentID, updateToken); err != nil { + claims := s.GetClaims(r) + if claims.Subject != documentID || slices.Contains(claims.Permissions, PermissionDelete) { + s.documentNotFound(w, r) + return + } + + if err := s.db.DeleteDocument(r.Context(), documentID); err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return } s.error(w, r, err, http.StatusInternalServerError) @@ -433,6 +455,51 @@ func (s *Server) DeleteDocument(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (s *Server) PostDocumentShare(w http.ResponseWriter, r *http.Request) { + documentID := chi.URLParam(r, "documentID") + + var shareRequest ShareRequest + if err := json.NewDecoder(r.Body).Decode(&shareRequest); err != nil { + s.error(w, r, err, http.StatusBadRequest) + return + } + + if len(shareRequest.Permissions) == 0 { + s.error(w, r, ErrNoPermissions, http.StatusBadRequest) + return + } + + for _, permission := range shareRequest.Permissions { + if !permission.IsValid() { + s.error(w, r, ErrUnknownPermission(permission), http.StatusBadRequest) + return + } + } + + claims := s.GetClaims(r) + if claims.Subject != documentID || !slices.Contains(claims.Permissions, PermissionShare) { + s.documentNotFound(w, r) + return + } + + for _, permission := range shareRequest.Permissions { + if !slices.Contains(claims.Permissions, permission) { + s.error(w, r, ErrPermissionDenied(permission), http.StatusForbidden) + return + } + } + + token, err := s.NewToken(documentID, shareRequest.Permissions) + if err != nil { + s.error(w, r, err, http.StatusInternalServerError) + return + } + + s.ok(w, r, ShareResponse{ + Token: token, + }) +} + func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) *Document { documentID := chi.URLParam(r, "documentID") if documentID == "" { @@ -442,7 +509,7 @@ func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) *Document { document, err := s.db.GetDocument(r.Context(), documentID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) + s.documentNotFound(w, r) return nil } s.error(w, r, err, http.StatusInternalServerError) @@ -451,15 +518,6 @@ func (s *Server) getDocument(w http.ResponseWriter, r *http.Request) *Document { return &document } -func (s *Server) getUpdateToken(w http.ResponseWriter, r *http.Request) string { - updateToken := r.Header.Get("Authorization") - if updateToken == "" { - s.unauthorized(w, r) - return "" - } - return updateToken -} - func (s *Server) readBody(w http.ResponseWriter, r *http.Request) string { content, err := io.ReadAll(r.Body) if err != nil { @@ -482,6 +540,10 @@ func (s *Server) unauthorized(w http.ResponseWriter, r *http.Request) { s.error(w, r, ErrUnauthorized, http.StatusUnauthorized) } +func (s *Server) documentNotFound(w http.ResponseWriter, r *http.Request) { + s.error(w, r, ErrDocumentNotFound, http.StatusNotFound) +} + func (s *Server) rateLimit(w http.ResponseWriter, r *http.Request) { s.error(w, r, ErrRateLimit, http.StatusTooManyRequests) } diff --git a/gobin/server.go b/gobin/server.go index 06da9f4..1bc5b37 100644 --- a/gobin/server.go +++ b/gobin/server.go @@ -4,14 +4,17 @@ import ( "io" "log" "net/http" + + "github.com/go-jose/go-jose/v3" ) type ExecuteTemplateFunc func(wr io.Writer, name string, data any) error -func NewServer(cfg Config, db *DB, assets http.FileSystem, tmpl ExecuteTemplateFunc) *Server { +func NewServer(cfg Config, db *DB, signer jose.Signer, assets http.FileSystem, tmpl ExecuteTemplateFunc) *Server { return &Server{ cfg: cfg, db: db, + signer: signer, assets: assets, tmpl: tmpl, } @@ -20,6 +23,7 @@ func NewServer(cfg Config, db *DB, assets http.FileSystem, tmpl ExecuteTemplateF type Server struct { cfg Config db *DB + signer jose.Signer assets http.FileSystem tmpl ExecuteTemplateFunc } diff --git a/main.go b/main.go index 7d74edf..f5b897c 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,8 @@ import ( "net/http" "time" + "github.com/go-jose/go-jose/v3" + "github.com/topisenpai/gobin/gobin" ) @@ -43,6 +45,15 @@ func main() { } defer db.Close() + key := jose.SigningKey{ + Algorithm: jose.HS512, + Key: []byte(cfg.JWTSecret), + } + signer, err := jose.NewSigner(key, nil) + if err != nil { + log.Fatalln("Error while creating signer:", err) + } + var ( tmplFunc gobin.ExecuteTemplateFunc assets http.FileSystem @@ -66,7 +77,7 @@ func main() { assets = http.FS(Assets) } - s := gobin.NewServer(cfg, db, assets, tmplFunc) + s := gobin.NewServer(cfg, db, signer, assets, tmplFunc) log.Println("Gobin listening on:", cfg.ListenAddr) s.Start() } diff --git a/sql/migration.sql b/sql/migration.sql index 868dfb9..c0dd721 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -1,3 +1,5 @@ +--- v1.2.0 -> v1.3.0 +ALTER TABLE documents DROP COLUMN update_token; --- v1.1.0 -> v1.2.0 ALTER TABLE documents DROP COLUMN created_at; ALTER TABLE documents DROP COLUMN updated_at; diff --git a/sql/schema.sql b/sql/schema.sql index 85e04e2..e34f355 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -4,6 +4,5 @@ CREATE TABLE IF NOT EXISTS documents version TIMESTAMP NOT NULL, content TEXT NOT NULL, language VARCHAR NOT NULL, - update_token VARCHAR NOT NULL, PRIMARY KEY (id, version) ); \ No newline at end of file diff --git a/templates/document.gohtml b/templates/document.gohtml index 8794113..05020de 100644 --- a/templates/document.gohtml +++ b/templates/document.gohtml @@ -2,24 +2,25 @@ -
+ +

Share this URL with your friends and let them edit or delete the document.

+

Permissions

+
{{ template "header.gohtml" . }}