commit 71c9ff7b254bff94b0d4db7237ea0cf88b00cbc0
parent b7dac46c6dc28ae4ff4d7463c3aad1d63756bc54
Author: mtmn <miro@haravara.org>
Date: Thu, 23 Apr 2026 14:51:46 +0200
feat: hash user token
Diffstat:
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