2023-06-03 23:07:55 -06:00
package main
import (
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"os"
2026-07-04 10:03:18 -06:00
"path/filepath"
"strings"
2023-06-04 12:32:18 -06:00
"sync"
2026-07-04 10:03:18 -06:00
"time"
2023-06-03 23:07:55 -06:00
)
// Information for a LineageOS ROM available for download
type LineageOSROM struct {
2026-07-04 10:03:18 -06:00
Datetime int `json:"datetime"` // Unix timestamp - i.e. 1685907926
Filename string `json:"filename"` // .zip filename - i.e. lineage-20.0-20230604-UNOFFICIAL-sunfish.zip
ID string `json:"id"` // A unique identifier such as a SHA256 hash of the .zip - i.e. 603bfc02e403e5fd1bf9ed74383f1d6c9ec7fb228d03c4b37753033d79488e93
Romtype string `json:"romtype"` // i.e. NIGHTLY or UNOFFICIAL
Size int `json:"size"` // size of .zip file in bytes
URL string `json:"url"` // Accessible URL where client could download the .zip file
Version string `json:"version"` // LineageOS version - i.e. 20.0
// v2 API fields
SHA1 string `json:"sha1,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Date string `json:"date,omitempty"`
OSPatchLevel string `json:"os_patch_level,omitempty"`
OSSDKLevel int `json:"os_sdk_level,omitempty"`
OTAPropertyFiles string `json:"ota_property_files,omitempty"`
}
// Structs for v2 API responses
type V2BuildResponse struct {
Date string `json:"date"`
Datetime int64 `json:"datetime"`
Files [] V2BuildFile `json:"files"`
Type string `json:"type"`
Version string `json:"version"`
}
type V2BuildFile struct {
Date string `json:"date,omitempty"`
Datetime int64 `json:"datetime,omitempty"`
Filename string `json:"filename"`
Filepath string `json:"filepath"`
OSPatchLevel string `json:"os_patch_level,omitempty"`
OSSDKLevel int `json:"os_sdk_level,omitempty"`
OTAPropertyFiles string `json:"ota_property_files,omitempty"`
SHA1 string `json:"sha1"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
Type string `json:"type,omitempty"`
URL string `json:"url"`
2023-06-03 23:07:55 -06:00
}
// The HTTP response JSON should be a JSON array of lineageOSROMS available for download
2023-06-04 12:32:18 -06:00
type HTTPResponseJSON struct {
2023-06-03 23:07:55 -06:00
Response [] LineageOSROM `json:"response"`
}
2023-06-04 15:52:08 -06:00
// Caches data about available ROMs in memory so we don't need to reference the filesystem for each request
2023-06-04 12:32:18 -06:00
type ROMCache struct {
2023-06-30 19:50:47 -06:00
ROMs [] LineageOSROM `json:"roms"`
Cached map [ string ] bool `json:"-"` // to quickly lookup if a file is already cached
2026-07-04 10:03:18 -06:00
sync . RWMutex `json:"-"` // We have multiple goroutines that may be accessing this data simultaneously, so we must lock / unlock it to prevent race conditions
2023-06-04 12:32:18 -06:00
}
2023-06-04 16:15:13 -06:00
const (
2023-06-30 20:11:49 -06:00
romDirectory = "public" // directory where ROMs are available for download
buildOutDirectory = "out" // directory from build system containing artifacts which we can move to romDirectory
cacheFile = "public/romcache.json" // persistence between server restarts so we don't have to rehash all the ROM files each time the program starts
2023-06-04 16:15:13 -06:00
)
2023-08-21 19:47:49 -06:00
var ( // evil global variables
2023-06-04 12:32:18 -06:00
romCache ROMCache
2023-08-21 19:47:49 -06:00
baseURL string
2023-06-04 12:32:18 -06:00
)
func init () {
2023-06-24 13:36:23 -06:00
// intialize and load ROMCache from file - so we don't have to rehash all the big files again
2023-06-04 12:41:22 -06:00
romCache = ROMCache {}
2023-08-21 19:47:49 -06:00
baseURL = os . Getenv ( "baseurl" )
if len ( baseURL ) < 1 {
log . Fatalf ( "required environment variable 'baseurl' is not set." )
}
2023-06-26 10:34:18 -06:00
2023-06-24 13:36:23 -06:00
romCacheJson , err := os . ReadFile ( cacheFile )
if err != nil {
if err != os . ErrNotExist { // don't care if it doesn't exist, just skip it
log . Printf ( "failed to read romCache file : %v" , err )
}
} else { // if opening the file's successful, then load the contents
err = json . Unmarshal ( romCacheJson , & romCache )
if err != nil {
log . Printf ( "failed to unmarshal romCacheJson to romCache struct: %v" , err )
}
}
2023-06-26 10:34:18 -06:00
romCache . Cached = make ( map [ string ] bool )
2026-07-04 10:03:18 -06:00
var validROMs [] LineageOSROM
2023-06-24 13:36:23 -06:00
for _ , rom := range romCache . ROMs {
2026-07-04 10:03:18 -06:00
// A ROM is valid for v2 if it has SHA1 and Date fields populated
if rom . SHA1 != "" && rom . Date != "" {
romCache . Cached [ rom . Filename ] = true
validROMs = append ( validROMs , rom )
log . Printf ( "loaded cached file: %s" , rom . Filename )
} else {
log . Printf ( "cache entry for %s is missing v2 metadata, will re-process" , rom . Filename )
}
2023-06-24 13:36:23 -06:00
}
2026-07-04 10:03:18 -06:00
romCache . ROMs = validROMs
2023-06-04 13:28:36 -06:00
2023-06-30 20:11:49 -06:00
// Check if any new build artifacts and load any new files into the romCache
2023-06-04 16:15:13 -06:00
moveBuildArtifacts ()
go updateROMCache ()
2023-06-04 12:32:18 -06:00
}
2023-06-04 15:52:08 -06:00
// HTTP Server
2023-06-03 23:07:55 -06:00
func main () {
//Public static files
http . Handle ( "/public/" , http . StripPrefix ( "/public/" , http . FileServer ( http . Dir ( "public" ))))
2026-07-04 10:03:18 -06:00
// ROM list (legacy / catch-all)
2023-06-03 23:08:49 -06:00
http . HandleFunc ( "/" , lineageOSROMListHandler )
2023-06-03 23:07:55 -06:00
2026-07-04 10:03:18 -06:00
// V2 ROM list and device info
http . HandleFunc ( "/api/v2/devices/" , lineageOSROMListHandlerV2 )
2023-06-03 23:07:55 -06:00
log . Print ( "Service listening on :8080" )
log . Fatal ( http . ListenAndServe ( ":8080" , nil ))
}
2026-07-04 10:03:18 -06:00
// Reads the ROM files on the filesystem recursively and populates a slice of lineageOSROMs
2023-06-04 16:15:13 -06:00
func updateROMCache () {
2023-06-03 23:07:55 -06:00
2023-06-04 13:34:09 -06:00
log . Printf ( "updating ROM cache" )
2026-07-04 10:03:18 -06:00
type fileInfoPath struct {
path string
info fs . DirEntry
splitName [] string
2023-06-04 12:56:58 -06:00
}
2026-07-04 10:03:18 -06:00
var romFiles [] fileInfoPath
err := filepath . WalkDir ( romDirectory , func ( path string , d fs . DirEntry , err error ) error {
if err != nil {
return err
}
if d . IsDir () {
return nil
}
isLineageROM , splitName := parseROMFileName ( d )
if isLineageROM {
romFiles = append ( romFiles , fileInfoPath {
path : path ,
info : d ,
splitName : splitName ,
})
}
return nil
})
2023-06-04 12:56:58 -06:00
if err != nil {
2026-07-04 10:03:18 -06:00
log . Printf ( "failed to walk ROM directory: %v" , err )
2023-06-04 12:32:18 -06:00
return
2023-06-03 23:07:55 -06:00
}
2023-06-26 10:34:18 -06:00
wg := sync . WaitGroup {}
2026-07-04 10:03:18 -06:00
for i := range romFiles {
v := romFiles [ i ]
2023-06-03 23:07:55 -06:00
2023-06-04 12:32:18 -06:00
// skip already cached files
2023-06-30 19:59:51 -06:00
romCache . RLock ()
2026-07-04 10:03:18 -06:00
if _ , ok := romCache . Cached [ v . info . Name ()]; ok {
2023-06-30 19:59:51 -06:00
romCache . RUnlock ()
2023-06-04 12:56:58 -06:00
continue
2023-06-03 23:07:55 -06:00
}
2023-06-30 19:59:51 -06:00
romCache . RUnlock ()
2023-06-03 23:07:55 -06:00
2023-06-26 10:34:18 -06:00
wg . Add ( 1 )
2026-07-04 10:03:18 -06:00
go func ( file fileInfoPath ) {
2023-06-26 10:34:18 -06:00
defer wg . Done ()
2026-07-04 10:03:18 -06:00
fInfo , err := file . info . Info ()
2023-06-04 12:32:18 -06:00
if err != nil {
2026-07-04 10:03:18 -06:00
log . Printf ( "failed to get file info '%s': %v" , file . info . Name (), err )
2023-06-04 12:32:18 -06:00
return
}
2026-07-04 10:03:18 -06:00
sha1Val , sha256Val , err := hashFileSha1AndSha256 ( file . path )
2023-06-04 12:32:18 -06:00
if err != nil {
2026-07-04 10:03:18 -06:00
log . Printf ( "ignore zip file '%s', failed to calculate hashes: %v" , file . info . Name (), err )
2023-06-04 12:32:18 -06:00
return
}
2026-07-04 10:03:18 -06:00
// Read ZIP metadata
meta , err := readZipMetadata ( file . path )
if err != nil {
log . Printf ( "failed to read metadata inside zip '%s': %v" , file . info . Name (), err )
}
// Determine timestamp
timestamp := int ( fInfo . ModTime (). Unix ())
if meta . Timestamp > 0 {
timestamp = meta . Timestamp
}
// Format build date as YYYY-MM-DD
buildDate := time . Unix ( int64 ( timestamp ), 0 ). UTC (). Format ( "2006-01-02" )
// Calculate URL relative path from public directory
relPath , err := filepath . Rel ( romDirectory , file . path )
if err != nil {
log . Printf ( "failed to calculate relative path for '%s': %v" , file . path , err )
relPath = file . info . Name ()
}
relPathSlash := filepath . ToSlash ( relPath )
2023-06-04 12:32:18 -06:00
lineageOSROM := LineageOSROM {
2026-07-04 10:03:18 -06:00
Datetime : timestamp ,
Filename : file . info . Name (),
ID : sha256Val , // Keep ID as SHA256 for backward compatibility
Romtype : file . splitName [ 3 ],
Size : int ( fInfo . Size ()),
URL : fmt . Sprintf ( "%s/public/%s" , baseURL , relPathSlash ),
Version : file . splitName [ 1 ],
SHA1 : sha1Val ,
SHA256 : sha256Val ,
Date : buildDate ,
OSPatchLevel : meta . PatchLevel ,
OSSDKLevel : meta . SDKLevel ,
OTAPropertyFiles : meta . OTAPropertyFiles ,
2023-06-04 12:32:18 -06:00
}
romCache . Lock ()
2026-07-04 10:03:18 -06:00
if _ , ok := romCache . Cached [ file . info . Name ()]; ok { // it's possible another goroutine was already working to cache the file, so we check at the end
2023-06-04 12:56:58 -06:00
romCache . Unlock ()
return
}
2023-06-04 12:32:18 -06:00
romCache . ROMs = append ( romCache . ROMs , lineageOSROM )
2026-07-04 10:03:18 -06:00
romCache . Cached [ file . info . Name ()] = true
2023-06-04 12:32:18 -06:00
romCache . Unlock ()
2023-06-26 10:34:18 -06:00
2026-07-04 10:03:18 -06:00
}( v )
2023-06-03 23:07:55 -06:00
}
2023-06-24 13:36:23 -06:00
// save file to disk for next startup so we don't have to rehash all the files again
2023-06-26 10:34:18 -06:00
wg . Wait ()
2023-06-30 19:50:47 -06:00
romCache . RLock ()
2023-06-24 13:36:23 -06:00
romCacheJson , err := json . Marshal ( romCache )
2023-06-30 19:50:47 -06:00
romCache . RUnlock ()
2023-06-24 13:36:23 -06:00
if err != nil {
log . Printf ( "failed to marshal romCache to json: %v" , err )
return
}
err = os . WriteFile ( cacheFile , romCacheJson , 0644 )
if err != nil {
log . Printf ( "failed to write '%s' file: %v" , cacheFile , err )
log . Printf ( "attempting to remove '%s' to ensure integrity during next program startup..." , cacheFile )
err = os . Remove ( cacheFile )
if err != nil {
log . Printf ( "failed to remove file '%s': %v" , cacheFile , err )
}
return
}
2023-06-03 23:07:55 -06:00
}
// http - GET /
2023-06-30 20:11:49 -06:00
// The LineageOS updater app needs a JSON array of type LineageOSROM
2026-07-04 10:03:18 -06:00
// Marshals romCache.ROMs to JSON to serve as the response body
2023-06-03 23:07:55 -06:00
func lineageOSROMListHandler ( w http . ResponseWriter , r * http . Request ) {
2026-07-04 10:03:18 -06:00
checkNewBuilds ()
2023-06-04 15:58:16 -06:00
2023-06-30 19:59:51 -06:00
romCache . RLock ()
2023-06-04 15:58:16 -06:00
httpResponseJSON := & HTTPResponseJSON { Response : romCache . ROMs }
2023-06-30 19:59:51 -06:00
romCache . RUnlock ()
2023-06-03 23:07:55 -06:00
b , err := json . Marshal ( httpResponseJSON )
if err != nil {
w . WriteHeader ( http . StatusInternalServerError )
log . Printf ( "failed to marshal lineageOSROMs to json: %v" , err )
return
}
w . Write ( b )
}
2026-07-04 10:03:18 -06:00
func checkNewBuilds () {
go func () { // Also checks for new builds
newBuilds := moveBuildArtifacts ()
if newBuilds {
updateROMCache ()
}
}()
}
func lineageOSROMListHandlerV2 ( w http . ResponseWriter , r * http . Request ) {
checkNewBuilds ()
// Path: /api/v2/devices/{device}/builds
// or: /api/v2/devices/{device}
parts := strings . Split ( strings . Trim ( r . URL . Path , "/" ), "/" )
if len ( parts ) < 4 {
http . Error ( w , "Bad Request" , http . StatusBadRequest )
return
}
device := parts [ 3 ]
isBuildsRequest := len ( parts ) >= 5 && parts [ 4 ] == "builds"
if isBuildsRequest {
romCache . RLock ()
defer romCache . RUnlock ()
var builds [] V2BuildResponse
for _ , rom := range romCache . ROMs {
filenameParts := strings . Split ( strings . TrimSuffix ( rom . Filename , ".zip" ), "-" )
romDevice := ""
if len ( filenameParts ) > 0 {
romDevice = filenameParts [ len ( filenameParts ) - 1 ]
}
if romDevice != device {
continue
}
relPath := rom . Filename
if strings . Contains ( rom . URL , "/public/" ) {
parts := strings . SplitN ( rom . URL , "/public/" , 2 )
if len ( parts ) == 2 {
relPath = parts [ 1 ]
}
}
file := V2BuildFile {
Date : rom . Date ,
Datetime : int64 ( rom . Datetime ),
Filename : rom . Filename ,
Filepath : "/public/" + relPath ,
OSPatchLevel : rom . OSPatchLevel ,
OSSDKLevel : rom . OSSDKLevel ,
OTAPropertyFiles : rom . OTAPropertyFiles ,
SHA1 : rom . SHA1 ,
SHA256 : rom . SHA256 ,
Size : int64 ( rom . Size ),
Type : strings . ToLower ( rom . Romtype ),
URL : rom . URL ,
}
build := V2BuildResponse {
Date : rom . Date ,
Datetime : int64 ( rom . Datetime ),
Files : [] V2BuildFile { file },
Type : strings . ToLower ( rom . Romtype ),
Version : rom . Version ,
}
builds = append ( builds , build )
}
if builds == nil {
builds = [] V2BuildResponse {}
}
b , err := json . Marshal ( builds )
if err != nil {
w . WriteHeader ( http . StatusInternalServerError )
log . Printf ( "failed to marshal v2 builds to json: %v" , err )
return
}
w . Header (). Set ( "Content-Type" , "application/json" )
w . Write ( b )
} else {
romCache . RLock ()
defer romCache . RUnlock ()
versionsSet := make ( map [ string ] bool )
for _ , rom := range romCache . ROMs {
filenameParts := strings . Split ( strings . TrimSuffix ( rom . Filename , ".zip" ), "-" )
romDevice := ""
if len ( filenameParts ) > 0 {
romDevice = filenameParts [ len ( filenameParts ) - 1 ]
}
if romDevice == device {
versionsSet [ rom . Version ] = true
}
}
var versions [] string
for v := range versionsSet {
versions = append ( versions , v )
}
if versions == nil {
versions = [] string {}
}
deviceInfo := map [ string ] interface {}{
"name" : device ,
"model" : device ,
"oem" : "LineageOS" ,
"info_url" : fmt . Sprintf ( "https://wiki.lineageos.org/devices/%s/" , device ),
"versions" : versions ,
"dependencies" : [] string {},
}
b , err := json . Marshal ( deviceInfo )
if err != nil {
w . WriteHeader ( http . StatusInternalServerError )
log . Printf ( "failed to marshal v2 device info to json: %v" , err )
return
}
w . Header (). Set ( "Content-Type" , "application/json" )
w . Write ( b )
}
}