corpus

Log | Files | Refs | README | LICENSE

commit 71c9ff7b254bff94b0d4db7237ea0cf88b00cbc0
parent b7dac46c6dc28ae4ff4d7463c3aad1d63756bc54
Author: mtmn <miro@haravara.org>
Date:   Thu, 23 Apr 2026 14:51:46 +0200

feat: hash user token

Diffstat:
Mhack/nix-fake-hash | 4++++
Msrc/Db.js | 5+++++
Msrc/Db.purs | 21++++++++++++---------
Msrc/Main.purs | 65+++++++++++++++++++++++++++++++++++++++++------------------------
4 files changed, 62 insertions(+), 33 deletions(-)

diff --git a/hack/nix-fake-hash b/hack/nix-fake-hash @@ -15,4 +15,8 @@ sed -i "/elmDeps = pkgs.stdenv.mkDerivation/,/outputHash =/ s|outputHash = \"sha # Update spagoDeps outputHash sed -i "/spagoDeps = pkgs.stdenv.mkDerivation/,/outputHash =/ s|outputHash = \"sha256-[^\"]*\"|outputHash = \"$PLACEHOLDER\"|" flake.nix +nix build .#default --no-link 2>&1 | grep "got:" | sed 's/got: */npmDepsHash: /' || true +nix build .#elmDeps --no-link 2>&1 | grep "got:" | sed 's/got: */elmDeps: /' || true +nix build .#spagoDeps --no-link 2>&1 | grep "got:" | sed 's/got: */spagoDeps: /' || true + echo "done" diff --git a/src/Db.js b/src/Db.js @@ -1,4 +1,9 @@ import duckdb from "duckdb"; +import { createHash } from "crypto"; + +export const sha256 = (str) => { + return createHash("sha256").update(str).digest("hex"); +}; export const connectImpl = (path, cb) => () => { const db = new duckdb.Database(path, (err) => { diff --git a/src/Db.purs b/src/Db.purs @@ -53,6 +53,7 @@ foreign import connectImpl :: Fn2 String (Nullable Error -> Nullable Connection foreign import runImpl :: Fn4 Connection String (Array Foreign) (Nullable Error -> Effect Unit) (Effect Unit) foreign import allImpl :: Fn4 Connection String (Array Foreign) (Nullable Error -> Nullable (Array Json) -> Effect Unit) (Effect Unit) foreign import checkpointImpl :: Fn2 Connection (Nullable Error -> Effect Unit) (Effect Unit) +foreign import sha256 :: String -> String connect :: String -> Aff Connection connect path = makeAff \cb -> do @@ -147,18 +148,19 @@ queryAll conn sql params = makeAff \cb -> do initDb :: Connection -> Aff Unit initDb conn = do run conn "CREATE TABLE IF NOT EXISTS scrobbles (listened_at BIGINT PRIMARY KEY, track_name VARCHAR, artist_name VARCHAR, release_name VARCHAR, release_mbid VARCHAR, caa_release_mbid VARCHAR)" [] - run conn "CREATE TABLE IF NOT EXISTS api_tokens (slug VARCHAR PRIMARY KEY, token VARCHAR UNIQUE)" [] + run conn "CREATE TABLE IF NOT EXISTS api_tokens (slug VARCHAR PRIMARY KEY, hashed_token VARCHAR UNIQUE)" [] -getOrCreateToken :: Connection -> String -> Aff String +getOrCreateToken :: Connection -> String -> Aff (Maybe String) getOrCreateToken conn slug = do - rows <- queryAll conn "SELECT token FROM api_tokens WHERE slug = ?" [ unsafeCoerce slug ] - case rows !! 0 >>= toObject >>= Object.lookup "token" >>= toString of - Just token -> - pure token + rows <- queryAll conn "SELECT hashed_token FROM api_tokens WHERE slug = ?" [ unsafeCoerce slug ] + case rows !! 0 >>= toObject >>= Object.lookup "hashed_token" >>= toString of + Just _ -> + pure Nothing Nothing -> do token <- liftEffect $ map UUID.toString UUID.genUUID - run conn "INSERT INTO api_tokens (slug, token) VALUES (?, ?)" [ unsafeCoerce slug, unsafeCoerce token ] - pure token + let hashedToken = sha256 token + run conn "INSERT INTO api_tokens (slug, hashed_token) VALUES (?, ?)" [ unsafeCoerce slug, unsafeCoerce hashedToken ] + pure $ Just token checkExists :: Connection -> Int -> Aff Boolean checkExists conn ts = do @@ -387,5 +389,6 @@ rowToListen json = do getTokenUser :: Connection -> String -> Aff (Maybe String) getTokenUser conn token = do - rows <- queryAll conn "SELECT slug FROM api_tokens WHERE token = ?" [ unsafeCoerce token ] + let hashedToken = sha256 token + rows <- queryAll conn "SELECT slug FROM api_tokens WHERE hashed_token = ?" [ unsafeCoerce hashedToken ] pure $ rows !! 0 >>= toObject >>= Object.lookup "slug" >>= toString diff --git a/src/Main.purs b/src/Main.purs @@ -16,7 +16,7 @@ import Data.String (Pattern(..), stripPrefix) import Data.String.Regex (replace, parseFlags) import Data.String.Regex.Unsafe (unsafeRegex) import Data.Traversable (traverse) -import Db (Connection, FilterField(..), backupDb, connect, getOrCreateToken, getScrobbles, getStats, initDb, initReleaseMetadata, ping, upsertScrobble, withTransaction) +import Db (Connection, FilterField(..), backupDb, connect, getOrCreateToken, getTokenUser, getScrobbles, getStats, initDb, initReleaseMetadata, ping, upsertScrobble, withTransaction) import Effect (Effect) import Effect.Aff (Aff, Fiber, forkAff, joinFiber, killFiber, launchAff_, try) import Effect.Aff.AVar (AVar) @@ -57,7 +57,6 @@ type UserContext = , writeLock :: AVar Unit , config :: UserConfig , slug :: String - , token :: String , displayName :: String , enrichMetadataFiber :: Maybe (Fiber Unit) , backupFiber :: Maybe (Fiber Unit) @@ -116,10 +115,10 @@ routeRequest metricsEnabled contexts req url path allUsers res = liftEffect $ ca withUser url \ctx -> serveHealthz ctx.conn res "/1/submit-listens" -> if IM.method req == "POST" then - withUserFromToken contexts req res \ctx -> - launchAff_ $ serveSubmitListens ctx.conn ctx.writeLock req res + launchAff_ $ withUserFromToken contexts req res \ctx -> + serveSubmitListens ctx.conn ctx.writeLock req res else - serveBadRequest res "Method not allowed" + liftEffect $ serveBadRequest res "Method not allowed" _ -> case stripPrefix (Pattern "/u/") path of Just slug -> @@ -139,23 +138,40 @@ routeRequest metricsEnabled contexts req url path allUsers res = liftEffect $ ca Just ctx -> f ctx - withUserFromToken contextsParam reqParam resParam f = - let - headers = IM.headers reqParam - mAuth = Object.lookup "authorization" headers - mToken = mAuth >>= stripPrefix (Pattern "Token ") - in - case mToken of - Nothing -> do - Log.warn "Missing or invalid Authorization header" +withUserFromToken :: Array UserContext -> Request -> Response -> (UserContext -> Aff Unit) -> Aff Unit +withUserFromToken contextsParam reqParam resParam f = do + let + headers = IM.headers reqParam + mAuth = Object.lookup "authorization" headers + mToken = mAuth >>= stripPrefix (Pattern "Token ") + case mToken of + Nothing -> liftEffect $ do + Log.warn "Missing or invalid Authorization header" + serveUnauthorized resParam + Just tokenValue -> do + -- We need to check which connection to use to verify the token. + -- Since initDb creates the table in every user's database, but usually tokens are user-specific. + -- The current structure assumes each user has their own DB. + -- We'll try to find the user by checking each context's DB for the token. + mCtx <- findUserByToken contextsParam tokenValue + case mCtx of + Nothing -> liftEffect $ do + Log.warn $ "Unknown API token provided" serveUnauthorized resParam - Just tokenValue -> - case find (\c -> c.token == tokenValue) contextsParam of - Nothing -> do - Log.warn $ "Unknown API token provided" - serveUnauthorized resParam - Just ctx -> - f ctx + Just ctx -> + f ctx + +findUserByToken :: Array UserContext -> String -> Aff (Maybe UserContext) +findUserByToken contexts tokenValue = case Data.Array.uncons contexts of + Nothing -> + pure Nothing + Just { head: ctx, tail: rest } -> do + mSlug <- getTokenUser ctx.conn tokenValue + case mSlug of + Just slug | slug == ctx.slug -> + pure (Just ctx) + _ -> + findUserByToken rest tokenValue serveIndex :: Array { slug :: String, name :: String } -> String -> Response -> Effect Unit serveIndex allUsers slug res = do @@ -370,8 +386,9 @@ startUser { slug, name, config } = do initReleaseMetadata conn writeLock <- Avar.new unit - token <- getOrCreateToken conn slug - Log.info $ "User '" <> slug <> "' API token: " <> token + mToken <- getOrCreateToken conn slug + for_ mToken \token -> + Log.info $ "User '" <> slug <> "' token: " <> token case config.lastfmUser, config.lastfmApiKey of Just _, Nothing -> Log.warn $ "User '" <> slug <> "': Last.fm sync disabled (missing API key)" @@ -399,7 +416,7 @@ startUser { slug, name, config } = do else pure Nothing let displayName = fromMaybe (if slug == "" then "root" else slug) name - pure { conn, writeLock, config, slug, token, displayName, enrichMetadataFiber: Just enrichMetadataFiber, backupFiber } + pure { conn, writeLock, config, slug, displayName, enrichMetadataFiber: Just enrichMetadataFiber, backupFiber } cleanupUser :: UserContext -> Aff Unit cleanupUser ctx = do