@@ -0,0 +1,617 @@
import crypto from "node:crypto" ;
import fs from "node:fs" ;
import fsp from "node:fs/promises" ;
import path from "node:path" ;
import { fileURLToPath } from "node:url" ;
import express from "express" ;
import multer from "multer" ;
const _ _dirname = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
const projectRoot = path . resolve ( _ _dirname , ".." ) ;
const dataDir = path . join ( _ _dirname , "data" ) ;
const uploadDir = path . join ( _ _dirname , "uploads" ) ;
const packageDir = path . join ( uploadDir , "packages" ) ;
const iconDir = path . join ( uploadDir , "icons" ) ;
const dataFile = path . join ( dataDir , "software.json" ) ;
const playerFile = path . join ( dataDir , "players.json" ) ;
const port = Number ( process . env . PORT || 3030 ) ;
const adminToken = process . env . ADMIN _TOKEN || "sumi-admin" ;
const playerSessionCookie = "sumi_player_session" ;
const playerSessionTtlMs = Number ( process . env . PLAYER _SESSION _TTL _MS || 7 * 24 * 60 * 60 * 1000 ) ;
const passwordIterations = 120000 ;
const app = express ( ) ;
const playerSessions = new Map ( ) ;
function slugify ( value ) {
const slug = String ( value || "" )
. normalize ( "NFKD" )
. replace ( /[^\w\s.-]/g , "" )
. trim ( )
. replace ( /[\s_.]+/g , "-" )
. replace ( /-+/g , "-" )
. toLowerCase ( )
. slice ( 0 , 64 ) ;
return slug || ` software- ${ Date . now ( ) } ` ;
}
function parseBoolean ( value , fallback = false ) {
if ( value === undefined || value === null || value === "" ) return fallback ;
return [ "1" , "true" , "on" , "yes" ] . includes ( String ( value ) . toLowerCase ( ) ) ;
}
function parseTags ( value ) {
if ( ! value ) return [ ] ;
if ( Array . isArray ( value ) ) return value . flatMap ( parseTags ) ;
return String ( value )
. split ( /[,, \n]/ )
. map ( ( tag ) => tag . trim ( ) )
. filter ( Boolean )
. slice ( 0 , 8 ) ;
}
function formatSize ( bytes = 0 ) {
const units = [ "B" , "KB" , "MB" , "GB" ] ;
let size = Number ( bytes ) ;
let unitIndex = 0 ;
while ( size >= 1024 && unitIndex < units . length - 1 ) {
size /= 1024 ;
unitIndex += 1 ;
}
return ` ${ size . toFixed ( unitIndex === 0 ? 0 : 1 ) } ${ units [ unitIndex ] } ` ;
}
async function ensureStorage ( ) {
await Promise . all ( [
fsp . mkdir ( dataDir , { recursive : true } ) ,
fsp . mkdir ( packageDir , { recursive : true } ) ,
fsp . mkdir ( iconDir , { recursive : true } ) ,
] ) ;
try {
await fsp . access ( dataFile ) ;
} catch {
await writeStore ( { items : [ ] } ) ;
}
try {
await fsp . access ( playerFile ) ;
} catch {
await writePlayers ( {
players : [
createPlayerRecord ( process . env . PLAYER _USERNAME || "player" , process . env . PLAYER _PASSWORD || "sumi-player" ) ,
] ,
} ) ;
}
if ( process . env . PLAYER _USERNAME && process . env . PLAYER _PASSWORD ) {
await upsertEnvPlayer ( ) ;
}
}
async function readStore ( ) {
const raw = await fsp . readFile ( dataFile , "utf8" ) ;
const parsed = JSON . parse ( raw || "{}" ) ;
return { items : Array . isArray ( parsed . items ) ? parsed . items : [ ] } ;
}
async function writeStore ( store ) {
const tmpFile = ` ${ dataFile } . ${ process . pid } .tmp ` ;
await fsp . writeFile ( tmpFile , ` ${ JSON . stringify ( { items : store . items } , null, 2)} \n ` , "utf8" ) ;
await fsp . rename ( tmpFile , dataFile ) ;
}
async function readPlayers ( ) {
const raw = await fsp . readFile ( playerFile , "utf8" ) ;
const parsed = JSON . parse ( raw || "{}" ) ;
return { players : Array . isArray ( parsed . players ) ? parsed . players : [ ] } ;
}
async function writePlayers ( store ) {
const tmpFile = ` ${ playerFile } . ${ process . pid } .tmp ` ;
await fsp . writeFile ( tmpFile , ` ${ JSON . stringify ( { players : store . players } , null, 2)} \n ` , "utf8" ) ;
await fsp . rename ( tmpFile , playerFile ) ;
}
function createPlayerRecord ( username , password , existing = { } ) {
return {
... existing ,
username ,
displayName : existing . displayName || "玩家" ,
active : true ,
passwordHash : hashPassword ( password ) ,
updatedAt : new Date ( ) . toISOString ( ) ,
createdAt : existing . createdAt || new Date ( ) . toISOString ( ) ,
} ;
}
async function upsertEnvPlayer ( ) {
const username = process . env . PLAYER _USERNAME ;
const password = process . env . PLAYER _PASSWORD ;
const store = await readPlayers ( ) ;
const index = store . players . findIndex ( ( player ) => player . username === username ) ;
if ( index === - 1 ) {
store . players . push ( createPlayerRecord ( username , password ) ) ;
} else {
const existing = store . players [ index ] ;
if ( existing . active !== false && verifyPassword ( password , existing . passwordHash ) ) {
return ;
}
store . players [ index ] = createPlayerRecord ( username , password , store . players [ index ] ) ;
}
await writePlayers ( store ) ;
}
async function hashFile ( filePath ) {
const hash = crypto . createHash ( "sha256" ) ;
await new Promise ( ( resolve , reject ) => {
const stream = fs . createReadStream ( filePath ) ;
stream . on ( "data" , ( chunk ) => hash . update ( chunk ) ) ;
stream . on ( "error" , reject ) ;
stream . on ( "end" , resolve ) ;
} ) ;
return hash . digest ( "hex" ) ;
}
function hashPassword ( password , salt = crypto . randomBytes ( 16 ) . toString ( "hex" ) ) {
const hash = crypto . pbkdf2Sync ( String ( password ) , salt , passwordIterations , 32 , "sha256" ) . toString ( "hex" ) ;
return ` pbkdf2_sha256 $ ${ passwordIterations } $ ${ salt } $ ${ hash } ` ;
}
function verifyPassword ( password , encoded = "" ) {
const [ scheme , iterationText , salt , expectedHash ] = String ( encoded ) . split ( "$" ) ;
if ( scheme !== "pbkdf2_sha256" || ! iterationText || ! salt || ! expectedHash ) return false ;
const iterations = Number . parseInt ( iterationText , 10 ) ;
const actual = crypto . pbkdf2Sync ( String ( password ) , salt , iterations , 32 , "sha256" ) ;
const expected = Buffer . from ( expectedHash , "hex" ) ;
return expected . length === actual . length && crypto . timingSafeEqual ( expected , actual ) ;
}
function getToken ( req ) {
const auth = req . get ( "authorization" ) || "" ;
if ( auth . toLowerCase ( ) . startsWith ( "bearer " ) ) return auth . slice ( 7 ) . trim ( ) ;
return req . get ( "x-admin-token" ) || req . query . token || "" ;
}
function parseCookies ( req ) {
return Object . fromEntries (
String ( req . get ( "cookie" ) || "" )
. split ( ";" )
. map ( ( pair ) => pair . trim ( ) )
. filter ( Boolean )
. map ( ( pair ) => {
const index = pair . indexOf ( "=" ) ;
if ( index === - 1 ) return [ pair , "" ] ;
return [ pair . slice ( 0 , index ) , decodeURIComponent ( pair . slice ( index + 1 ) ) ] ;
} ) ,
) ;
}
function isHttpsRequest ( req ) {
return req . secure || req . get ( "x-forwarded-proto" ) === "https" ;
}
function setSessionCookie ( req , res , token ) {
const secure = isHttpsRequest ( req ) ? "; Secure" : "" ;
res . setHeader (
"Set-Cookie" ,
` ${ playerSessionCookie } = ${ encodeURIComponent ( token ) } ; HttpOnly; SameSite=Lax; Path=/; Max-Age= ${ Math . floor ( playerSessionTtlMs / 1000 ) } ${ secure } ` ,
) ;
}
function clearSessionCookie ( req , res ) {
const secure = isHttpsRequest ( req ) ? "; Secure" : "" ;
res . setHeader ( "Set-Cookie" , ` ${ playerSessionCookie } =; HttpOnly; SameSite=Lax; Path=/; Max-Age=0 ${ secure } ` ) ;
}
function publicPlayer ( player ) {
if ( ! player ) return null ;
return {
username : player . username ,
displayName : player . displayName || player . username ,
} ;
}
function getPlayerSession ( req ) {
const token = parseCookies ( req ) [ playerSessionCookie ] ;
if ( ! token ) return null ;
const session = playerSessions . get ( token ) ;
if ( ! session ) return null ;
if ( session . expiresAt <= Date . now ( ) ) {
playerSessions . delete ( token ) ;
return null ;
}
session . expiresAt = Date . now ( ) + playerSessionTtlMs ;
return { token , session } ;
}
function requireAdmin ( req , res , next ) {
if ( getToken ( req ) !== adminToken ) {
res . status ( 401 ) . json ( { error : "Invalid admin token" } ) ;
return ;
}
next ( ) ;
}
function requirePlayer ( req , res , next ) {
const playerSession = getPlayerSession ( req ) ;
if ( ! playerSession ) {
const loginUrl = ` /login.html?next= ${ encodeURIComponent ( req . originalUrl ) } ` ;
const accept = req . get ( "accept" ) || "" ;
if ( accept . includes ( "text/html" ) && ! accept . includes ( "application/json" ) ) {
res . redirect ( 302 , loginUrl ) ;
return ;
}
res . status ( 401 ) . json ( { error : "Player login required" , loginUrl } ) ;
return ;
}
req . player = playerSession . session . player ;
next ( ) ;
}
function publicItem ( item , includePrivate = false ) {
const copy = { ... item } ;
if ( ! includePrivate ) {
delete copy . storagePath ;
delete copy . iconStoragePath ;
delete copy . externalDownloadUrl ;
if ( resolveDownloadUrl ( item ) ) {
copy . downloadUrl = ` /api/software/ ${ encodeURIComponent ( item . id ) } /download ` ;
}
}
return copy ;
}
function sortSoftware ( items ) {
return [ ... items ] . sort ( ( a , b ) => {
if ( Boolean ( a . featured ) !== Boolean ( b . featured ) ) return a . featured ? - 1 : 1 ;
if ( ( a . sort ? ? 100 ) !== ( b . sort ? ? 100 ) ) return ( a . sort ? ? 100 ) - ( b . sort ? ? 100 ) ;
return new Date ( b . updatedAt || 0 ) - new Date ( a . updatedAt || 0 ) ;
} ) ;
}
async function makeFilePatch ( files = { } ) {
const patch = { } ;
const packageFile = files . package ? . [ 0 ] ;
const iconFile = files . icon ? . [ 0 ] ;
if ( packageFile ) {
patch . storagePath = packageFile . path ;
patch . originalFileName = packageFile . originalname ;
patch . fileName = packageFile . filename ;
patch . sizeBytes = packageFile . size ;
patch . size = formatSize ( packageFile . size ) ;
patch . sha256 = await hashFile ( packageFile . path ) ;
patch . downloadUrl = ` /uploads/packages/ ${ encodeURIComponent ( packageFile . filename ) } ` ;
patch . externalDownloadUrl = "" ;
}
if ( iconFile ) {
patch . iconStoragePath = iconFile . path ;
patch . iconFileName = iconFile . filename ;
patch . iconUrl = ` /uploads/icons/ ${ encodeURIComponent ( iconFile . filename ) } ` ;
}
return patch ;
}
function getBodyPatch ( body , existing = { } ) {
const now = new Date ( ) . toISOString ( ) ;
return {
name : String ( body . name || existing . name || "" ) . trim ( ) ,
version : String ( body . version || existing . version || "1.0.0" ) . trim ( ) ,
category : String ( body . category || existing . category || "tools" ) . trim ( ) ,
description : String ( body . description || existing . description || "" ) . trim ( ) ,
changelog : String ( body . changelog || existing . changelog || "" ) . trim ( ) ,
publisher : String ( body . publisher || existing . publisher || "sumi.work" ) . trim ( ) ,
platform : String ( body . platform || existing . platform || "Windows" ) . trim ( ) ,
homepage : String ( body . homepage || existing . homepage || "" ) . trim ( ) ,
tags : parseTags ( body . tags ? ? existing . tags ) ,
sort : Number . parseInt ( body . sort ? ? existing . sort ? ? 100 , 10 ) ,
active : parseBoolean ( body . active , existing . active !== false ) ,
featured : parseBoolean ( body . featured , Boolean ( existing . featured ) ) ,
externalDownloadUrl : String ( body . downloadUrl || existing . externalDownloadUrl || "" ) . trim ( ) ,
updatedAt : now ,
} ;
}
function resolveDownloadUrl ( item ) {
return item . downloadUrl || item . externalDownloadUrl || "" ;
}
function isPathInside ( parent , child ) {
const relative = path . relative ( parent , child ) ;
return relative === "" || ( ! relative . startsWith ( ".." ) && ! path . isAbsolute ( relative ) ) ;
}
function resolvePackagePath ( item ) {
if ( item . storagePath ) return path . resolve ( item . storagePath ) ;
if ( item . downloadUrl ? . startsWith ( "/uploads/packages/" ) ) {
return path . resolve ( packageDir , path . basename ( decodeURIComponent ( item . downloadUrl ) ) ) ;
}
return "" ;
}
const storage = multer . diskStorage ( {
destination ( req , file , callback ) {
callback ( null , file . fieldname === "icon" ? iconDir : packageDir ) ;
} ,
filename ( req , file , callback ) {
const ext = path . extname ( file . originalname || "" ) . slice ( 0 , 24 ) ;
const base = slugify ( path . basename ( file . originalname || "upload" , ext ) ) ;
const suffix = crypto . randomBytes ( 6 ) . toString ( "hex" ) ;
callback ( null , ` ${ Date . now ( ) } - ${ suffix } - ${ base } ${ ext } ` ) ;
} ,
} ) ;
const upload = multer ( {
storage ,
limits : {
fileSize : Number ( process . env . MAX _UPLOAD _BYTES || 1024 * 1024 * 1024 ) ,
files : 2 ,
} ,
fileFilter ( req , file , callback ) {
if ( file . fieldname === "icon" && ! file . mimetype . startsWith ( "image/" ) ) {
callback ( new Error ( "Icon must be an image file" ) ) ;
return ;
}
callback ( null , true ) ;
} ,
} ) ;
app . disable ( "x-powered-by" ) ;
app . use ( express . json ( { limit : "1mb" } ) ) ;
app . use ( express . urlencoded ( { extended : true } ) ) ;
app . use ( "/uploads/icons" , express . static ( iconDir , { fallthrough : false } ) ) ;
app . get ( "/api/health" , ( req , res ) => {
res . json ( { ok : true , service : "sumi-software-center" } ) ;
} ) ;
app . get ( "/api/auth/me" , ( req , res ) => {
const playerSession = getPlayerSession ( req ) ;
if ( ! playerSession ) {
res . json ( { authenticated : false , player : null } ) ;
return ;
}
setSessionCookie ( req , res , playerSession . token ) ;
res . json ( { authenticated : true , player : publicPlayer ( playerSession . session . player ) } ) ;
} ) ;
app . post ( "/api/auth/login" , async ( req , res , next ) => {
try {
const username = String ( req . body . username || "" ) . trim ( ) ;
const password = String ( req . body . password || "" ) ;
const nextUrl = String ( req . body . next || "/software.html" ) ;
const { players } = await readPlayers ( ) ;
const player = players . find ( ( entry ) => entry . username === username && entry . active !== false ) ;
if ( ! player || ! verifyPassword ( password , player . passwordHash ) ) {
res . status ( 401 ) . json ( { error : "账号或密码错误" } ) ;
return ;
}
const token = crypto . randomBytes ( 32 ) . toString ( "base64url" ) ;
playerSessions . set ( token , {
player : publicPlayer ( player ) ,
createdAt : Date . now ( ) ,
expiresAt : Date . now ( ) + playerSessionTtlMs ,
} ) ;
setSessionCookie ( req , res , token ) ;
res . json ( { authenticated : true , player : publicPlayer ( player ) , next : nextUrl } ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . post ( "/api/auth/logout" , ( req , res ) => {
const playerSession = getPlayerSession ( req ) ;
if ( playerSession ) {
playerSessions . delete ( playerSession . token ) ;
}
clearSessionCookie ( req , res ) ;
res . json ( { authenticated : false } ) ;
} ) ;
app . get ( "/api/software" , async ( req , res , next ) => {
try {
const { items } = await readStore ( ) ;
const category = String ( req . query . category || "all" ) ;
const keyword = String ( req . query . keyword || "" ) . trim ( ) . toLowerCase ( ) ;
const visibleItems = items
. filter ( ( item ) => item . active !== false )
. filter ( ( item ) => category === "all" || ! category || item . category === category )
. filter ( ( item ) => {
if ( ! keyword ) return true ;
return [ item . name , item . description , item . publisher , ... ( item . tags || [ ] ) ]
. join ( " " )
. toLowerCase ( )
. includes ( keyword ) ;
} ) ;
res . json ( { items : sortSoftware ( visibleItems ) . map ( ( item ) => publicItem ( item ) ) } ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . get ( "/api/software/:id/latest" , async ( req , res , next ) => {
try {
const { items } = await readStore ( ) ;
const item = items . find ( ( entry ) => entry . id === req . params . id && entry . active !== false ) ;
if ( ! item ) {
res . status ( 404 ) . json ( { error : "Software not found" } ) ;
return ;
}
res . json ( {
id : item . id ,
name : item . name ,
version : item . version ,
changelog : item . changelog ,
updatedAt : item . updatedAt ,
size : item . size ,
sizeBytes : item . sizeBytes ,
sha256 : item . sha256 ,
downloadUrl : ` /api/software/ ${ encodeURIComponent ( item . id ) } /download ` ,
} ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . get ( "/api/software/:id/download" , requirePlayer , async ( req , res , next ) => {
try {
const store = await readStore ( ) ;
const item = store . items . find ( ( entry ) => entry . id === req . params . id && entry . active !== false ) ;
if ( ! item ) {
res . status ( 404 ) . json ( { error : "Software not found" } ) ;
return ;
}
const downloadUrl = resolveDownloadUrl ( item ) ;
if ( ! downloadUrl ) {
res . status ( 404 ) . json ( { error : "No package has been uploaded" } ) ;
return ;
}
item . downloadCount = ( item . downloadCount || 0 ) + 1 ;
await writeStore ( store ) ;
const packagePath = resolvePackagePath ( item ) ;
if ( packagePath ) {
if ( ! isPathInside ( packageDir , packagePath ) ) {
res . status ( 403 ) . json ( { error : "Invalid package path" } ) ;
return ;
}
await fsp . access ( packagePath ) ;
res . download ( packagePath , item . originalFileName || item . fileName || path . basename ( packagePath ) ) ;
return ;
}
res . redirect ( downloadUrl ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . get ( "/api/admin/software" , requireAdmin , async ( req , res , next ) => {
try {
const { items } = await readStore ( ) ;
res . json ( { items : sortSoftware ( items ) . map ( ( item ) => publicItem ( item , true ) ) } ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . post ( "/api/admin/software" , requireAdmin , upload . fields ( [ { name : "package" , maxCount : 1 } , { name : "icon" , maxCount : 1 } ] ) , async ( req , res , next ) => {
try {
const bodyPatch = getBodyPatch ( req . body ) ;
if ( ! bodyPatch . name ) {
res . status ( 400 ) . json ( { error : "Software name is required" } ) ;
return ;
}
const store = await readStore ( ) ;
const baseId = slugify ( bodyPatch . name ) ;
let id = baseId ;
while ( store . items . some ( ( item ) => item . id === id ) ) {
id = ` ${ baseId } - ${ crypto . randomBytes ( 3 ) . toString ( "hex" ) } ` ;
}
const filePatch = await makeFilePatch ( req . files ) ;
const now = new Date ( ) . toISOString ( ) ;
const externalDownloadUrl = bodyPatch . externalDownloadUrl ;
const item = {
id ,
createdAt : now ,
downloadCount : 0 ,
... bodyPatch ,
... filePatch ,
downloadUrl : filePatch . downloadUrl || externalDownloadUrl ,
externalDownloadUrl : filePatch . downloadUrl ? "" : externalDownloadUrl ,
} ;
store . items . push ( item ) ;
await writeStore ( store ) ;
res . status ( 201 ) . json ( { item : publicItem ( item , true ) } ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . put ( "/api/admin/software/:id" , requireAdmin , upload . fields ( [ { name : "package" , maxCount : 1 } , { name : "icon" , maxCount : 1 } ] ) , async ( req , res , next ) => {
try {
const store = await readStore ( ) ;
const index = store . items . findIndex ( ( item ) => item . id === req . params . id ) ;
if ( index === - 1 ) {
res . status ( 404 ) . json ( { error : "Software not found" } ) ;
return ;
}
const existing = store . items [ index ] ;
const bodyPatch = getBodyPatch ( req . body , existing ) ;
if ( ! bodyPatch . name ) {
res . status ( 400 ) . json ( { error : "Software name is required" } ) ;
return ;
}
const filePatch = await makeFilePatch ( req . files ) ;
const downloadUrl = filePatch . downloadUrl || bodyPatch . externalDownloadUrl || existing . downloadUrl || "" ;
const item = {
... existing ,
... bodyPatch ,
... filePatch ,
downloadUrl ,
externalDownloadUrl : filePatch . downloadUrl ? "" : bodyPatch . externalDownloadUrl ,
} ;
store . items [ index ] = item ;
await writeStore ( store ) ;
res . json ( { item : publicItem ( item , true ) } ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . delete ( "/api/admin/software/:id" , requireAdmin , async ( req , res , next ) => {
try {
const store = await readStore ( ) ;
const initialLength = store . items . length ;
store . items = store . items . filter ( ( item ) => item . id !== req . params . id ) ;
if ( store . items . length === initialLength ) {
res . status ( 404 ) . json ( { error : "Software not found" } ) ;
return ;
}
await writeStore ( store ) ;
res . status ( 204 ) . end ( ) ;
} catch ( error ) {
next ( error ) ;
}
} ) ;
app . get ( "/software" , ( req , res ) => res . redirect ( 301 , "/software.html" ) ) ;
app . get ( "/software/" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "software.html" ) ) ) ;
app . get ( "/login" , ( req , res ) => res . redirect ( 301 , "/login.html" ) ) ;
app . get ( "/admin/software" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "admin" , "software.html" ) ) ) ;
app . get ( "/" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "index.html" ) ) ) ;
app . get ( "/index.html" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "index.html" ) ) ) ;
app . get ( "/software.html" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "software.html" ) ) ) ;
app . get ( "/login.html" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "login.html" ) ) ) ;
app . get ( "/admin/software.html" , ( req , res ) => res . sendFile ( path . join ( projectRoot , "admin" , "software.html" ) ) ) ;
app . use ( "/assets" , express . static ( path . join ( projectRoot , "assets" ) , { fallthrough : false } ) ) ;
app . use ( ( error , req , res , next ) => {
console . error ( error ) ;
res . status ( 500 ) . json ( { error : error . message || "Internal server error" } ) ;
} ) ;
await ensureStorage ( ) ;
app . listen ( port , ( ) => {
console . log ( ` sumi.work software center running at http://localhost: ${ port } ` ) ;
if ( ! process . env . ADMIN _TOKEN ) {
console . log ( "ADMIN_TOKEN is not set. Using development token: sumi-admin" ) ;
}
if ( ! process . env . PLAYER _USERNAME || ! process . env . PLAYER _PASSWORD ) {
console . log ( "PLAYER_USERNAME/PLAYER_PASSWORD is not set. Using development player: player / sumi-player" ) ;
}
} ) ;