├── README.md ├── cmd └── chromedb │ └── main.go ├── cookies.go ├── go.mod ├── go.sum ├── localstorage.go └── sessionstorage.go /README.md: -------------------------------------------------------------------------------- 1 |

2 | chromedb logo
Because chromiumdb is a bit of a mouthful for a Go package name. 3 |

4 | 5 | Read Chromium data (namely, cookies and local storage) straight from disk—_without_ spinning up the browser. I primarily use this to extract tokens from authenticated browser sessions for use in automation, scraping, etc. 6 | 7 | ## Description 8 | 9 | Chromium-based browsers store cookies and local storage in the following respective databases within the profile directory: 10 | 11 | Path | Format | Encrypted 12 | --- | --- | --- 13 | `Cookies` | SQLite | Yes 14 | `Local Storage/leveldb/` | LevelDB | No 15 | 16 | This tool reads from those databases, decrypts where necessary, and outputs the data in JSON format for easy parsing on CLI. 17 | 18 | ## Getting started 19 | 20 | ### Prerequisites 21 | 22 | This has only been tested with Arc browser on macOS, but should work with any Chromium-based browser. I'd accept a PR to support decrypting cookies on other operating systems. 23 | 24 | ### Install 25 | 26 | ```bash 27 | 𝄢 go install -v github.com/noperator/chromedb/cmd/chromedb@latest 28 | ``` 29 | 30 | ### Usage 31 | 32 | ```bash 33 | 𝄢 chromedb -h 34 | Usage of chromedb: 35 | -c cookies 36 | -ls 37 | local storage 38 | -p string 39 | path to browser profile directory 40 | 41 | ``` 42 | 43 | To decrypt cookies for Chromium-based Arc browser, we need to first get its password from the keychain. 44 | 45 | ```bash 46 | 𝄢 export BROWSER_PASSWORD=$(security find-generic-password -wga Arc) 47 | 𝄢 chromedb -c -p ~/Library/Application\ Support/Arc/User\ Data/Profile\ 1/ | 48 | shuf -n 2 | 49 | jq '.encrypted_value = ""' 50 | 51 | { 52 | "domain": ".geeksforgeeks.org", 53 | "name": "gfg_theme", 54 | "encrypted_value": "", 55 | "value": "gfgThemeDark" 56 | } 57 | { 58 | "domain": "www.citrix.com", 59 | "name": "renderid", 60 | "encrypted_value": "", 61 | "value": "rend01" 62 | } 63 | ``` 64 | 65 | Local storage is unencrypted and doesn't require a password. 66 | 67 | ```bash 68 | 𝄢 chromedb -ls -p ~/Library/Application\ Support/Arc/User\ Data/Profile\ 1/ | 69 | shuf -n 2 | 70 | jq 71 | 72 | { 73 | "storage_key": "https://docs.paloaltonetworks.com", 74 | "script_key": "ClientSidePersistence", 75 | "charset": "ISO-8859-1", 76 | "mime": "text/plain", 77 | "conversions": [ 78 | "strconv.Quote" 79 | ], 80 | "value": "ClientContext/CLIENTCONTEXT:=visitorId%3D" 81 | } 82 | { 83 | "storage_key": "https://github.com", 84 | "script_key": "ref-selector:greasysock/railscookie:branch", 85 | "charset": "ISO-8859-1", 86 | "mime": "application/json", 87 | "conversions": [], 88 | "value": { 89 | "refs": [ 90 | "master" 91 | ], 92 | "cacheKey": "v0:1554731422.0" 93 | } 94 | } 95 | ``` 96 | 97 | ## Back matter 98 | 99 | ### See also 100 | 101 | Three outstanding posts from CCL Solutions Group that helped me understand Chromium data storage: 102 | - [LevelDB](https://www.cclsolutionsgroup.com/post/hang-on-thats-not-sqlite-chrome-electron-and-leveldb) 103 | - [IndexedDB](https://www.cclsolutionsgroup.com/post/indexeddb-on-chromium) 104 | - [Local storage](https://www.cclsolutionsgroup.com/post/chromium-session-storage-and-local-storage) 105 | 106 | I almost didn't write this tool as there are many others that do this kind of thing already. The most widely used ones are written in Python ([`pycookiecheat`](https://github.com/n8henrie/pycookiecheat/blob/dev/src/pycookiecheat/chrome.py) for cookies, [`ccl_chrome_indexeddb`](https://github.com/cclgroupltd/ccl_chrome_indexeddb/blob/master/ccl_chromium_localstorage.py) for local storage)—but I avoid using Python if possible due [nightmarish environment management](https://xkcd.com/1987/). There are a few Go-based cookie-dumping utilities, but they: 107 | 108 | - don't read from disk, and instead abuse the [remote debugging port](https://blog.chromium.org/2011/05/remote-debugging-with-chrome-developer.html) to launch a browser and dump unencrypted cookies ([`WhiteChocolateMacademiaNut`](https://github.com/slyd0g/WhiteChocolateMacademiaNut), [`chromecookiestealer`](https://github.com/magisterquis/chromecookiestealer), [`chrome-dump`](https://github.com/lesnuages/chrome-dump), [`go-chrome-stealer`](https://github.com/omaidf/go-chrome-stealer)) 109 | - only work on Windows ([`gookies`](https://github.com/CCob/gookies)) 110 | - are "obsolete" ([`gostealer`](https://github.com/4kord/gostealer)) or "demo-only" ([`go-stealer`](https://github.com/idfp/go-stealer)) 111 | - do read from disk, but either used too many hardcoded values or were too complex for my needs ([`go-chrome-cookies`](https://github.com/teocci/go-chrome-cookies), [`chrome-cookie`](https://github.com/muyids/chrome-cookie), [`ChromeDecryptor`](https://github.com/wat4r/ChromeDecryptor), [`chrome-cookie-cutter`](https://github.com/saranrapjs/chrome-cookie-cutter), [`chrome-cookie-decrypt`](https://github.com/kinghrothgar/chrome-cookie-decrypt), [`chrome-cookies`](https://github.com/igara/chrome-cookies), [`cookies`](https://github.com/creachadair/cookies)) 112 | 113 | I wasn't able to find _any_ Go-based tools that specifically parse local storage (though [`leveldb-cli`](https://github.com/cions/leveldb-cli) does read LevelDB, which is a good start). 114 | 115 |
(I really did try.) 116 | 117 | 118 | ```bash 119 | # Search for tools written in Go that process Chrom(e|ium)'s cookies or local storage. 120 | for QUERY in \ 121 | 'chrome cookie' \ 122 | 'chromium cookie' \ 123 | 'chrome leveldb' \ 124 | 'chromium leveldb' \ 125 | 'chrome local storage' \ 126 | 'chromium local storage' \ 127 | ; do 128 | echo "query: $QUERY" >&2 129 | curl -sv "https://github.com/search?type=repositories" \ 130 | -G -X GET --data-urlencode "q=$QUERY language:Go" \ 131 | -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' | 132 | tee >/dev/null \ 133 | >(htmlq -t .search-title | nl -nln | sed -E 's/(^[0-9]+)/\1.1/') \ 134 | >(htmlq 'li > a' -a 'aria-label' | nl -nln | sed -E 's/(^[0-9]+)/\1.2/') | 135 | sort -V | 136 | sed -E 's/^[0-9\.]+\s+//' | 137 | paste -d @ - - | 138 | tee /dev/stderr 139 | sleep 2 140 | done | 141 | sed -E 's/ stars$//' | 142 | sort -u | 143 | tee repos.lst 144 | 145 | # Download all of those tools' repos. 146 | mkdir -p repos 147 | cd repos 148 | cat ../repos.lst | parallel --colsep @ --bar 'git clone --depth 1 https://github.com/{1} $(echo {1} | sed -E "s|/|--|")--{2}' 149 | rm -rf ./*/.git 150 | 151 | # Look for various topics that may or may not indicate that the tool uses the approach I care about. 152 | echo " 153 | sql sql|db|database|query|row 154 | remote ws://|remote|debugg(ing|er)|9222|cdp|dev.?tools|debug.port 155 | keychain log.?in|keychain 156 | crypto key.?length|iteration|aes|cbc|sha1?|pbkdf|cipher|crypt 157 | localStorage ldb|leveldb|local.?storage 158 | " | grep -v '^$' | while read TOPIC REGEX; do 159 | rg -g '!.git*' -g '!topics.jsonl' -c "$REGEX" . | while read RESULT; do 160 | FILE=$(echo "$RESULT" | cut -d : -f 1 | sed -E 's|\./||') 161 | REPO="https://github.com/"$(echo "$FILE" | grep -oE '[^/]+' | head -n 1 | sed -E 's|--|/|; s/--.*//') 162 | STARS=$(echo "$FILE" | grep -oE '\--[0-9]+/' | head -n 1 | grep -oE '[0-9]+') 163 | COUNT=$(echo "$RESULT" | cut -d : -f 2) 164 | jo topic="$TOPIC" repo="$REPO" file="$FILE" count="$COUNT" stars="$STARS" 165 | done 166 | done | tee topics.jsonl 167 | 168 | # Sort relevant repos by popularity. 169 | cat topics.jsonl | jq -sc 'group_by(.repo) | map({ 170 | repo: .[0].repo, 171 | stars: .[0].stars, 172 | topics: map({topic, count}) | 173 | group_by(.topic) | 174 | map({topic: .[0].topic, count: map(.count) | add}) 175 | }) | sort_by(.stars) | reverse[]' 176 | 177 | {"repo":"https://github.com/slyd0g/WhiteChocolateMacademiaNut","stars":141,"topics":[{"topic":"crypto","count":5},{"topic":"remote","count":17},{"topic":"sql","count":6}]} 178 | {"repo":"https://github.com/magisterquis/chromecookiestealer","stars":92,"topics":[{"topic":"crypto","count":6},{"topic":"keychain","count":3},{"topic":"remote","count":20},{"topic":"sql","count":15}]} 179 | {"repo":"https://github.com/CCob/gookies","stars":48,"topics":[{"topic":"crypto","count":36},{"topic":"sql","count":14}]} 180 | {"repo":"https://github.com/cions/leveldb-cli","stars":27,"topics":[{"topic":"crypto","count":14},{"topic":"localStorage","count":60},{"topic":"sql","count":290}]} 181 | {"repo":"https://github.com/lesnuages/chrome-dump","stars":18,"topics":[{"topic":"crypto","count":11},{"topic":"keychain","count":1},{"topic":"remote","count":13}]} 182 | {"repo":"https://github.com/teocci/go-chrome-cookies","stars":9,"topics":[{"topic":"crypto","count":140},{"topic":"keychain","count":42},{"topic":"sql","count":283}]} 183 | {"repo":"https://github.com/idfp/go-stealer","stars":9,"topics":[{"topic":"crypto","count":73},{"topic":"keychain","count":32},{"topic":"sql","count":66}]} 184 | {"repo":"https://github.com/muyids/chrome-cookie","stars":8,"topics":[{"topic":"crypto","count":22},{"topic":"keychain","count":3},{"topic":"sql","count":25}]} 185 | {"repo":"https://github.com/wat4r/ChromeDecryptor","stars":5,"topics":[{"topic":"crypto","count":107},{"topic":"keychain","count":9},{"topic":"sql","count":35}]} 186 | {"repo":"https://github.com/saranrapjs/chrome-cookie-cutter","stars":5,"topics":[{"topic":"crypto","count":29},{"topic":"sql","count":10}]} 187 | {"repo":"https://github.com/omaidf/go-chrome-stealer","stars":5,"topics":[{"topic":"remote","count":3}]} 188 | {"repo":"https://github.com/kawakatz/macCookies","stars":3,"topics":[{"topic":"crypto","count":68},{"topic":"keychain","count":4},{"topic":"sql","count":30}]} 189 | {"repo":"https://github.com/4kord/gostealer","stars":3,"topics":[{"topic":"crypto","count":52},{"topic":"keychain","count":13},{"topic":"sql","count":242}]} 190 | {"repo":"https://github.com/kinghrothgar/chrome-cookie-decrypt","stars":1,"topics":[{"topic":"crypto","count":45},{"topic":"keychain","count":12},{"topic":"sql","count":55}]} 191 | {"repo":"https://github.com/kalelc/go-rails-cook","stars":1,"topics":[{"topic":"crypto","count":25},{"topic":"sql","count":4}]} 192 | {"repo":"https://github.com/hybridtheory/samesite-cookie-support","stars":1,"topics":[{"topic":"crypto","count":2},{"topic":"sql","count":24}]} 193 | {"repo":"https://github.com/m4tt72/rails-cookie-decrypt-go","stars":0,"topics":[{"topic":"crypto","count":31},{"topic":"sql","count":1}]} 194 | {"repo":"https://github.com/igara/chrome-cookies","stars":0,"topics":[{"topic":"crypto","count":31},{"topic":"sql","count":12}]} 195 | {"repo":"https://github.com/greasysock/railscookie","stars":0,"topics":[{"topic":"crypto","count":13},{"topic":"sql","count":1}]} 196 | {"repo":"https://github.com/corenting/cookies","stars":0,"topics":[{"topic":"crypto","count":24},{"topic":"remote","count":1},{"topic":"sql","count":10}]} 197 | ``` 198 | 199 |
200 | 201 | ### To-do 202 | 203 | - [ ] decrypt cookies on Linux, Windows 204 | - [ ] specify a domain to filter on 205 | - [ ] clean up error handling, logging 206 | - [x] support session storage 207 | -------------------------------------------------------------------------------- /cmd/chromedb/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/noperator/chromedb" 11 | ) 12 | 13 | func main() { 14 | 15 | browserPath := flag.String("p", "", "path to browser profile directory (required)") 16 | cookies := flag.Bool("c", false, "cookies") 17 | localStorage := flag.Bool("ls", false, "local storage") 18 | sessionStorage := flag.Bool("ss", false, "session storage") 19 | 20 | flag.Parse() 21 | 22 | if *browserPath == "" { 23 | fmt.Println("Error: -p flag (path to browser profile directory) is required") 24 | flag.Usage() 25 | os.Exit(1) 26 | } 27 | 28 | // Check for mutually exclusive flags. 29 | flagCount := 0 30 | if *cookies { 31 | flagCount++ 32 | } 33 | if *localStorage { 34 | flagCount++ 35 | } 36 | if *sessionStorage { 37 | flagCount++ 38 | } 39 | 40 | if flagCount != 1 { 41 | fmt.Println("Error: Please specify exactly one of -c, -ls, or -ss") 42 | flag.Usage() 43 | os.Exit(1) 44 | } 45 | 46 | if *cookies { 47 | 48 | cookiesPath := filepath.Join(*browserPath, "Cookies") 49 | cookies, err := chromedb.GetCookies(cookiesPath) 50 | if err != nil { 51 | fmt.Println("Error opening Cookies database:", err) 52 | os.Exit(1) 53 | } 54 | 55 | key, err := chromedb.GetKey() 56 | if err != nil { 57 | fmt.Println("Error getting key:", err) 58 | os.Exit(1) 59 | } 60 | 61 | for _, c := range cookies { 62 | if len(c.EncryptedValue) > 0 { 63 | value, err := chromedb.DecryptValue(c.EncryptedValue, key, c.Domain) 64 | if err != nil { 65 | fmt.Printf("Failed to decrypt cookie %s: %v\n", c.Name, err) 66 | } 67 | c.Value = value 68 | } 69 | 70 | j, err := json.Marshal(c) 71 | if err != nil { 72 | fmt.Println("Error converting cookie to JSON:", err) 73 | os.Exit(1) 74 | } 75 | 76 | fmt.Println(string(j)) 77 | } 78 | } 79 | 80 | if *localStorage { 81 | localStoragePath := filepath.Join(*browserPath, "Local Storage/leveldb") 82 | 83 | lsd, err := chromedb.LoadLocalStorage(localStoragePath) 84 | if err != nil { 85 | fmt.Println("Error opening LevelDB:", err) 86 | os.Exit(1) 87 | } 88 | defer lsd.Close() 89 | 90 | for _, r := range lsd.Records { 91 | j, err := chromedb.LocalStorageRecordToJson(r) 92 | if err != nil { 93 | fmt.Println("Error converting record to JSON:", err) 94 | os.Exit(1) 95 | } 96 | 97 | fmt.Println(j) 98 | } 99 | } 100 | 101 | if *sessionStorage { 102 | sessionStoragePath := filepath.Join(*browserPath, "Session Storage") 103 | 104 | ssd, err := chromedb.LoadSessionStorage(sessionStoragePath) 105 | if err != nil { 106 | fmt.Println("Error opening LevelDB:", err) 107 | os.Exit(1) 108 | } 109 | defer ssd.Close() 110 | 111 | for _, r := range ssd.Records { 112 | j, err := chromedb.SessionStorageRecordToJson(r) 113 | if err != nil { 114 | fmt.Println("Error converting record to JSON:", err) 115 | os.Exit(1) 116 | } 117 | 118 | fmt.Println(j) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /cookies.go: -------------------------------------------------------------------------------- 1 | package chromedb 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/sha1" 8 | "crypto/sha256" 9 | "database/sql" 10 | "fmt" 11 | "os" 12 | "strings" 13 | 14 | _ "github.com/mattn/go-sqlite3" 15 | "golang.org/x/crypto/pbkdf2" 16 | ) 17 | 18 | type Cookie struct { 19 | Domain string `json:"domain"` 20 | Name string `json:"name"` 21 | EncryptedValue []byte `json:"encrypted_value"` 22 | Value string `json:"value"` 23 | } 24 | 25 | func GetCookies(cookiesPath string) ([]Cookie, error) { 26 | 27 | db, err := sql.Open("sqlite3", cookiesPath) 28 | if err != nil { 29 | return nil, err 30 | } 31 | defer db.Close() 32 | 33 | // Check the database version - we'll use this later for decryption 34 | var dbVersion int 35 | row := db.QueryRow("SELECT value FROM meta WHERE key = 'version'") 36 | if err := row.Scan(&dbVersion); err != nil { 37 | // If we can't get the version, assume it's an older version 38 | dbVersion = 0 39 | } 40 | 41 | // Store the database version in a package variable for DecryptValue to use 42 | currentDBVersion = dbVersion 43 | 44 | // query := "SELECT name, value, host_key, encrypted_value FROM cookies WHERE host_key like ?" 45 | // rows, err := db.Query(query, fmt.Sprintf("%%%s%%", domain)) 46 | query := "SELECT name, value, host_key, encrypted_value FROM cookies" 47 | rows, err := db.Query(query) 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer rows.Close() 52 | 53 | var cookies []Cookie 54 | for rows.Next() { 55 | var cookie Cookie 56 | err := rows.Scan(&cookie.Name, &cookie.Value, &cookie.Domain, &cookie.EncryptedValue) 57 | if err != nil { 58 | return nil, err 59 | } 60 | cookies = append(cookies, cookie) 61 | } 62 | 63 | return cookies, nil 64 | } 65 | 66 | func GetKey() ([]byte, error) { 67 | browserPassword := os.Getenv("BROWSER_PASSWORD") 68 | if browserPassword == "" { 69 | return []byte{}, fmt.Errorf("BROWSER_PASSWORD environment variable not set") 70 | } 71 | password := strings.TrimSpace(string(browserPassword)) 72 | return pbkdf2.Key([]byte(password), []byte("saltysalt"), 1003, 16, sha1.New), nil 73 | } 74 | 75 | // Package variable to store the current database version 76 | var currentDBVersion int 77 | 78 | func DecryptValue(encryptedValue, key []byte, domain string) (string, error) { 79 | block, err := aes.NewCipher(key) 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | // The EncryptedValue is prefixed with "v10", remove it 85 | // TODO check if prefix is v10 86 | if len(encryptedValue) < 3 { 87 | return "", fmt.Errorf("encrypted length less than 3") 88 | } 89 | version := string(encryptedValue[0:3]) 90 | if version != "v10" { 91 | return "", fmt.Errorf("unsported encrypted value version: %s", version) 92 | } 93 | encryptedValue = encryptedValue[3:] 94 | decrypted := make([]byte, len(encryptedValue)) 95 | const ( 96 | aescbcSalt = `saltysalt` 97 | aescbcIV = ` ` 98 | aescbcIterationsLinux = 1 99 | aescbcIterationsMacOS = 1003 100 | aescbcLength = 16 101 | ) 102 | cbc := cipher.NewCBCDecrypter(block, []byte(aescbcIV)) 103 | cbc.CryptBlocks(decrypted, encryptedValue) 104 | 105 | if len(decrypted) == 0 { 106 | return "", fmt.Errorf("not enough bits") 107 | } 108 | 109 | if len(decrypted)%aescbcLength != 0 { 110 | return "", fmt.Errorf("decrypted data block length is not a multiple of %d", aescbcLength) 111 | } 112 | paddingLen := int(decrypted[len(decrypted)-1]) 113 | if paddingLen > 16 { 114 | return "", fmt.Errorf("invalid last block padding length: %d", paddingLen) 115 | } 116 | 117 | // In Chrome database versions ≥ 24, the first 32 bytes contain a SHA256 digest of the host_key (domain) 118 | // This was added in Chrome v130 (https://github.com/chromium/chromium/commit/5ea6d65c622a3d5ff75db9dc0257ea3869f31289) 119 | if currentDBVersion >= 24 { 120 | // Need to verify and skip the first 32 bytes (SHA256 digest of domain) 121 | if len(decrypted) <= 32 { 122 | return "", fmt.Errorf("decrypted data too short for db version %d, expected more than 32 bytes but got %d", currentDBVersion, len(decrypted)) 123 | } 124 | 125 | // If domain is provided, verify the SHA256 hash matches 126 | if domain != "" { 127 | // Calculate SHA256 hash of the domain 128 | domainHash := sha256.Sum256([]byte(domain)) 129 | 130 | // Check if the hash in the decrypted value matches the calculated hash 131 | if !bytes.Equal(domainHash[:], decrypted[:32]) { // SHA256 is 32 bytes 132 | return "", fmt.Errorf("domain hash verification failed") 133 | } 134 | } 135 | 136 | return string(decrypted[32:len(decrypted)-paddingLen]), nil 137 | } 138 | 139 | // For older versions, return the full decrypted value (minus padding) 140 | return string(decrypted[:len(decrypted)-paddingLen]), nil 141 | } 142 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/noperator/chromedb 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/h2non/filetype v1.1.3 7 | github.com/mattn/go-sqlite3 v1.14.22 8 | github.com/syndtr/goleveldb v1.0.0 9 | golang.org/x/crypto v0.23.0 10 | golang.org/x/text v0.15.0 11 | google.golang.org/protobuf v1.34.1 12 | ) 13 | 14 | require github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 2 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 3 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= 4 | github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 5 | github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= 6 | github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 7 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 8 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 9 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 10 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 11 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 12 | github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= 13 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 14 | github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= 15 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 16 | github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= 17 | github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= 18 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 19 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 20 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 21 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= 22 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 23 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 24 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 25 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 26 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 29 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 30 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 31 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 33 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 34 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 35 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 36 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 37 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 38 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 39 | -------------------------------------------------------------------------------- /localstorage.go: -------------------------------------------------------------------------------- 1 | package chromedb 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "github.com/h2non/filetype" 18 | "github.com/syndtr/goleveldb/leveldb" 19 | "github.com/syndtr/goleveldb/leveldb/opt" 20 | "github.com/syndtr/goleveldb/leveldb/storage" 21 | "golang.org/x/text/encoding/unicode" 22 | "google.golang.org/protobuf/encoding/protowire" 23 | ) 24 | 25 | func fromChromeTimestamp(microseconds int64) (time.Time, error) { 26 | chromiumEpoch := time.Date(1601, 1, 1, 0, 0, 0, 0, time.UTC).UnixMicro() 27 | microFromEpoch := chromiumEpoch + microseconds 28 | timestamp := time.Unix(0, microFromEpoch*1000) 29 | return timestamp, nil 30 | } 31 | 32 | func decodeString(raw []byte) (string, string, error) { 33 | prefix := raw[0] 34 | if prefix == 0 { 35 | decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder() 36 | utf8bytes, err := decoder.Bytes(raw[1:]) 37 | if err != nil { 38 | return "", "", fmt.Errorf("failed to decode UTF-16-LE: %w", err) 39 | } 40 | return string(utf8bytes), "UTF-16-LE", nil 41 | } else if prefix == 1 { 42 | return string(raw[1:]), "ISO-8859-1", nil 43 | } 44 | return "", "", fmt.Errorf("unknown string encoding prefix: %d", prefix) 45 | } 46 | 47 | type StorageMetadata struct { 48 | StorageKey string `json:"storage_key"` 49 | Timestamp time.Time `json:"timestamp"` 50 | Size int `json:"size"` 51 | } 52 | 53 | type LocalStorageRecord struct { 54 | StorageKey string `json:"storage_key"` 55 | ScriptKey string `json:"script_key"` 56 | Charset string `json:"charset"` 57 | Decoded string `json:"-"` 58 | MIME string `json:"mime"` 59 | Conversions []string `json:"conversions"` 60 | JsonType string `json:"-"` 61 | Value json.RawMessage `json:"value"` 62 | } 63 | 64 | type LocalStoreDb struct { 65 | ldb *leveldb.DB 66 | Records []LocalStorageRecord `json:"records"` 67 | metadata []StorageMetadata `json:"metadata"` 68 | } 69 | 70 | func StorageMetadataFromProtobuff(sm *StorageMetadata, data []byte) error { 71 | 72 | fieldNum, wireType, n := protowire.ConsumeTag(data) 73 | if fieldNum != 1 || wireType != protowire.VarintType { 74 | return fmt.Errorf("Expected field number 1 with varint type, got field number %d with wire type %d", fieldNum, wireType) 75 | } 76 | timestamp, m := protowire.ConsumeVarint(data[n:]) 77 | if m < 0 { 78 | return fmt.Errorf("Failed to decode timestamp") 79 | } 80 | 81 | fieldNum, wireType, n = protowire.ConsumeTag(data[n+m:]) 82 | if fieldNum != 2 || wireType != protowire.VarintType { 83 | return fmt.Errorf("Expected field number 2 with varint type, got field number %d with wire type %d", fieldNum, wireType) 84 | } 85 | size, m := protowire.ConsumeVarint(data[n+m:]) 86 | if m < 0 { 87 | return fmt.Errorf("Failed to decode size") 88 | } 89 | 90 | ts, err := fromChromeTimestamp(int64(timestamp)) 91 | if err != nil { 92 | return fmt.Errorf("Failed to decode timestamp: %w", err) 93 | } 94 | 95 | sm.Timestamp = ts 96 | sm.Size = int(size) 97 | 98 | return nil 99 | } 100 | 101 | func LoadLocalStorage(dir string) (*LocalStoreDb, error) { 102 | db := &leveldb.DB{} 103 | db, err := leveldb.OpenFile(dir, &opt.Options{ 104 | ReadOnly: true, 105 | }) 106 | 107 | // We try the ReadOnly option above, but it weirdly doesn't work when the 108 | // db is locked. When this happens, we simply copy the db to memory and 109 | // read from there. 110 | if err != nil { 111 | 112 | srcDir := dir 113 | 114 | memStorage := storage.NewMemStorage() 115 | 116 | // Copy the LevelDB directory contents into the memory storage 117 | err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { 118 | 119 | if err != nil { 120 | return err 121 | } 122 | 123 | relPath, err := filepath.Rel(srcDir, path) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | // Skip directories, we only need files 129 | if info.IsDir() { 130 | return nil 131 | } 132 | 133 | srcFile, err := os.Open(path) 134 | if err != nil { 135 | return err 136 | } 137 | defer srcFile.Close() 138 | 139 | data, err := io.ReadAll(srcFile) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | var num int64 145 | num = 0 146 | re := regexp.MustCompile(`\d+`) 147 | match := re.FindString(relPath) 148 | if match != "" { 149 | 150 | matchInt, err := strconv.Atoi(match) 151 | if err == nil { 152 | num = int64(matchInt) 153 | } 154 | } 155 | 156 | // Determine the file descriptor type 157 | var fileType storage.FileType 158 | switch { 159 | case strings.HasSuffix(relPath, ".ldb"): 160 | fileType = storage.TypeTable 161 | case strings.HasPrefix(relPath, "MANIFEST"): 162 | fileType = storage.TypeManifest 163 | case strings.HasSuffix(relPath, ".log"): 164 | fileType = storage.TypeJournal 165 | case strings.HasSuffix(relPath, ".tmp"): 166 | fileType = storage.TypeTemp 167 | default: 168 | return nil 169 | } 170 | 171 | // Create the file in the memory storage 172 | fd := storage.FileDesc{Type: fileType, Num: num} 173 | if fd.Type == storage.TypeManifest { 174 | err = memStorage.SetMeta(fd) 175 | if err != nil { 176 | return err 177 | } 178 | } 179 | writer, err := memStorage.Create(fd) 180 | if err != nil { 181 | return err 182 | } 183 | 184 | // Write the contents to the memory storage 185 | _, err = writer.Write(data) 186 | if err != nil { 187 | writer.Close() 188 | return err 189 | } 190 | 191 | // Close the writer 192 | err = writer.Close() 193 | if err != nil { 194 | return err 195 | } 196 | 197 | return nil 198 | 199 | }) 200 | 201 | if err != nil { 202 | fmt.Println("Error copying directory:", err) 203 | return nil, err 204 | } 205 | 206 | // Open the LevelDB using the memory storage 207 | db, err = leveldb.Open(memStorage, nil) 208 | if err != nil { 209 | fmt.Println("Error opening LevelDB:", err) 210 | return nil, err 211 | } 212 | 213 | } 214 | defer db.Close() 215 | 216 | lsd := &LocalStoreDb{ 217 | ldb: db, 218 | } 219 | 220 | iter := db.NewIterator(nil, nil) 221 | defer iter.Release() 222 | 223 | for iter.Next() { 224 | key := iter.Key() 225 | value := iter.Value() 226 | 227 | const ( 228 | MetaKeyPrefix = "META:" 229 | RecordKeyPrefix = "_" 230 | ) 231 | 232 | // metadata 233 | if bytes.HasPrefix(key, []byte(MetaKeyPrefix)) { 234 | storageKey := string(bytes.TrimPrefix(key, []byte(MetaKeyPrefix))) 235 | 236 | metadata := StorageMetadata{ 237 | StorageKey: storageKey, 238 | Timestamp: time.Time{}, 239 | Size: 0, 240 | } 241 | err := StorageMetadataFromProtobuff(&metadata, value) 242 | if err != nil { 243 | return nil, err 244 | } 245 | 246 | lsd.metadata = append(lsd.metadata, metadata) 247 | 248 | // record 249 | } else if bytes.HasPrefix(key, []byte(RecordKeyPrefix)) { 250 | parts := bytes.SplitN(bytes.TrimPrefix(key, []byte(RecordKeyPrefix)), []byte{0}, 2) 251 | if len(parts) != 2 { 252 | continue 253 | } 254 | 255 | record := LocalStorageRecord{} 256 | 257 | storageKey := string(parts[0]) 258 | record.StorageKey = storageKey 259 | sk, _, err := decodeString(parts[1]) 260 | if err != nil { 261 | return nil, fmt.Errorf("failed to decode script key: %w", err) 262 | } 263 | record.ScriptKey = sk 264 | 265 | val, valEnc, err := decodeString(value) 266 | if err != nil { 267 | return nil, fmt.Errorf("failed to decode value: %w", err) 268 | } 269 | record.Decoded = val 270 | record.Charset = valEnc 271 | 272 | lsd.Records = append(lsd.Records, record) 273 | } 274 | } 275 | 276 | return lsd, nil 277 | } 278 | 279 | func LocalStorageRecordToJson(r LocalStorageRecord) (string, error) { 280 | 281 | mime := "application/octet-stream" 282 | xfer := []string{} 283 | b := []byte(r.Decoded) 284 | validJson := json.Valid(b) 285 | jsonType := "" 286 | out := []byte{} 287 | 288 | if validJson { 289 | // Use a custom decoder to handle large numbers as strings 290 | d := json.NewDecoder(bytes.NewReader(b)) 291 | d.UseNumber() // This makes the decoder use json.Number for numbers 292 | var v interface{} 293 | err := d.Decode(&v) 294 | if err != nil { 295 | return "", fmt.Errorf("failed to unmarshal supposedly valid JSON: %w", err) 296 | } 297 | 298 | mime = "application/json" 299 | 300 | // Determine the type of the JSON value 301 | switch val := v.(type) { 302 | case json.Number: 303 | jsonType = "number" 304 | // Check if the number might be too large for float64 305 | _, err := val.Float64() 306 | if err != nil { 307 | // If it can't be converted to float64, treat it as a string in the output 308 | strVal := fmt.Sprintf("\"%s\"", val.String()) 309 | out = []byte(strVal) 310 | } else { 311 | out = b 312 | } 313 | case string: 314 | jsonType = "string" 315 | out = b 316 | case bool: 317 | jsonType = "boolean" 318 | out = b 319 | case []interface{}: 320 | jsonType = "array" 321 | out = b 322 | case map[string]interface{}: 323 | jsonType = "object" 324 | out = b 325 | case nil: 326 | jsonType = "null" 327 | out = b 328 | default: 329 | jsonType = "" 330 | out = b 331 | } 332 | } else { 333 | quoted := strconv.Quote(r.Decoded) 334 | if json.Valid([]byte(quoted)) { 335 | 336 | out = []byte(quoted) 337 | mime = "text/plain" 338 | xfer = append(xfer, "strconv.Quote") 339 | mime = http.DetectContentType(b) 340 | mime = strings.Split(mime, ";")[0] 341 | 342 | } else { 343 | 344 | b64 := base64.StdEncoding.EncodeToString(b) 345 | xfer = append(xfer, "base64.StdEncoding.EncodeToString") 346 | out = []byte(strconv.Quote(b64)) 347 | xfer = append(xfer, "strconv.Quote") 348 | 349 | magic, _ := filetype.Match(b) 350 | if magic != filetype.Unknown { 351 | mime = magic.MIME.Value 352 | } 353 | 354 | } 355 | } 356 | 357 | r.MIME = mime 358 | r.Conversions = xfer 359 | r.Value = out 360 | r.JsonType = jsonType 361 | recordJson, err := json.Marshal(r) 362 | if err != nil { 363 | return "", fmt.Errorf("failed to marshal record to JSON: %w", err) 364 | } 365 | 366 | return string(recordJson), nil 367 | } 368 | 369 | func (lsd *LocalStoreDb) Close() { 370 | lsd.ldb.Close() 371 | } 372 | -------------------------------------------------------------------------------- /sessionstorage.go: -------------------------------------------------------------------------------- 1 | package chromedb 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | 16 | // "time" 17 | 18 | "github.com/h2non/filetype" 19 | "github.com/syndtr/goleveldb/leveldb" 20 | "github.com/syndtr/goleveldb/leveldb/opt" 21 | "github.com/syndtr/goleveldb/leveldb/storage" 22 | "golang.org/x/text/encoding/unicode" 23 | ) 24 | 25 | type SessionStorageRecord struct { 26 | // Value string `json:"value"` 27 | MapID int `json:"map_id"` 28 | Key string `json:"key"` 29 | Value json.RawMessage `json:"value"` 30 | Charset string `json:"charset"` 31 | Decoded string `json:"-"` 32 | MIME string `json:"mime"` 33 | Conversions []string `json:"conversions"` 34 | JsonType string `json:"json_type"` 35 | } 36 | 37 | type SessionStoreDb struct { 38 | ldb *leveldb.DB 39 | Records []SessionStorageRecord `json:"records"` 40 | } 41 | 42 | func decodeUTF16LE(raw []byte) (string, error) { 43 | decoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewDecoder() 44 | utf8bytes, err := decoder.Bytes(raw) 45 | if err != nil { 46 | return "", fmt.Errorf("failed to decode UTF-16-LE: %w", err) 47 | } 48 | return string(utf8bytes), nil 49 | } 50 | 51 | func LoadSessionStorage(dir string) (*SessionStoreDb, error) { 52 | db, err := leveldb.OpenFile(dir, &opt.Options{ 53 | ReadOnly: true, 54 | }) 55 | 56 | if err != nil { 57 | srcDir := dir 58 | memStorage := storage.NewMemStorage() 59 | 60 | err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { 61 | if err != nil { 62 | return err 63 | } 64 | 65 | relPath, err := filepath.Rel(srcDir, path) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if info.IsDir() { 71 | return nil 72 | } 73 | 74 | srcFile, err := os.Open(path) 75 | if err != nil { 76 | return err 77 | } 78 | defer srcFile.Close() 79 | 80 | data, err := io.ReadAll(srcFile) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | var num int64 86 | num = 0 87 | re := regexp.MustCompile(`\d+`) 88 | match := re.FindString(relPath) 89 | if match != "" { 90 | matchInt, err := strconv.Atoi(match) 91 | if err == nil { 92 | num = int64(matchInt) 93 | } 94 | } 95 | 96 | var fileType storage.FileType 97 | switch { 98 | case strings.HasSuffix(relPath, ".ldb"): 99 | fileType = storage.TypeTable 100 | case strings.HasPrefix(relPath, "MANIFEST"): 101 | fileType = storage.TypeManifest 102 | case strings.HasSuffix(relPath, ".log"): 103 | fileType = storage.TypeJournal 104 | case strings.HasSuffix(relPath, ".tmp"): 105 | fileType = storage.TypeTemp 106 | default: 107 | return nil 108 | } 109 | 110 | fd := storage.FileDesc{Type: fileType, Num: num} 111 | if fd.Type == storage.TypeManifest { 112 | err = memStorage.SetMeta(fd) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | writer, err := memStorage.Create(fd) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | _, err = writer.Write(data) 123 | if err != nil { 124 | writer.Close() 125 | return err 126 | } 127 | 128 | err = writer.Close() 129 | if err != nil { 130 | return err 131 | } 132 | 133 | return nil 134 | }) 135 | 136 | if err != nil { 137 | fmt.Println("Error copying directory:", err) 138 | return nil, err 139 | } 140 | 141 | db, err = leveldb.Open(memStorage, nil) 142 | if err != nil { 143 | fmt.Println("Error opening LevelDB:", err) 144 | return nil, err 145 | } 146 | } 147 | defer db.Close() 148 | 149 | ssd := &SessionStoreDb{ 150 | ldb: db, 151 | } 152 | 153 | iter := db.NewIterator(nil, nil) 154 | defer iter.Release() 155 | 156 | for iter.Next() { 157 | key := iter.Key() 158 | value := iter.Value() 159 | 160 | if bytes.HasPrefix(key, []byte("map-")) { 161 | parts := bytes.SplitN(bytes.TrimPrefix(key, []byte("map-")), []byte("-"), 2) 162 | if len(parts) != 2 { 163 | continue 164 | } 165 | 166 | mapID, err := strconv.Atoi(string(parts[0])) 167 | if err != nil { 168 | return nil, fmt.Errorf("failed to decode map ID: %w", err) 169 | } 170 | 171 | keyStr := string(parts[1]) 172 | val, err := decodeUTF16LE(value) 173 | if err != nil { 174 | return nil, fmt.Errorf("failed to decode value: %w", err) 175 | } 176 | 177 | record := SessionStorageRecord{ 178 | MapID: mapID, 179 | Key: keyStr, 180 | // Value: val, 181 | Decoded: val, 182 | Charset: "UTF-16-LE", 183 | } 184 | 185 | ssd.Records = append(ssd.Records, record) 186 | } 187 | } 188 | 189 | return ssd, nil 190 | } 191 | 192 | func SessionStorageRecordToJson(r SessionStorageRecord) (string, error) { 193 | mime := "application/octet-stream" 194 | xfer := []string{} 195 | b := []byte(r.Decoded) 196 | validJson := json.Valid(b) 197 | jsonType := "" 198 | out := []byte{} 199 | 200 | if validJson { 201 | // Use a custom decoder to handle large numbers as strings 202 | d := json.NewDecoder(bytes.NewReader(b)) 203 | d.UseNumber() // This makes the decoder use json.Number for numbers 204 | var v interface{} 205 | err := d.Decode(&v) 206 | if err != nil { 207 | return "", fmt.Errorf("failed to unmarshal supposedly valid JSON: %w", err) 208 | } 209 | 210 | mime = "application/json" 211 | 212 | switch val := v.(type) { 213 | case json.Number: 214 | jsonType = "number" 215 | // Check if the number might be too large for float64 216 | _, err := val.Float64() 217 | if err != nil { 218 | // If it can't be converted to float64, treat it as a string in the output 219 | strVal := fmt.Sprintf("\"%s\"", val.String()) 220 | out = []byte(strVal) 221 | } else { 222 | out = b 223 | } 224 | case string: 225 | jsonType = "string" 226 | out = b 227 | case bool: 228 | jsonType = "boolean" 229 | out = b 230 | case []interface{}: 231 | jsonType = "array" 232 | out = b 233 | case map[string]interface{}: 234 | jsonType = "object" 235 | out = b 236 | case nil: 237 | jsonType = "null" 238 | out = b 239 | default: 240 | jsonType = "" 241 | out = b 242 | } 243 | } else { 244 | quoted := strconv.Quote(r.Decoded) 245 | if json.Valid([]byte(quoted)) { 246 | out = []byte(quoted) 247 | mime = "text/plain" 248 | xfer = append(xfer, "strconv.Quote") 249 | mime = http.DetectContentType(b) 250 | mime = strings.Split(mime, ";")[0] 251 | } else { 252 | b64 := base64.StdEncoding.EncodeToString(b) 253 | xfer = append(xfer, "base64.StdEncoding.EncodeToString") 254 | out = []byte(strconv.Quote(b64)) 255 | xfer = append(xfer, "strconv.Quote") 256 | 257 | magic, _ := filetype.Match(b) 258 | if magic != filetype.Unknown { 259 | mime = magic.MIME.Value 260 | } 261 | } 262 | } 263 | 264 | r.MIME = mime 265 | r.Conversions = xfer 266 | r.Value = out 267 | r.JsonType = jsonType 268 | recordJson, err := json.Marshal(r) 269 | if err != nil { 270 | return "", fmt.Errorf("failed to marshal record to JSON: %w", err) 271 | } 272 | 273 | return string(recordJson), nil 274 | } 275 | 276 | func (ssd *SessionStoreDb) Close() { 277 | ssd.ldb.Close() 278 | } 279 | --------------------------------------------------------------------------------