@d3iu/tiktok-quiz 0.0.0-stage → 2.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +720 -0
- package/README.md +88 -2
- package/agent.mjs +31 -0
- package/bin.mjs +41 -0
- package/configure.mjs +4 -0
- package/lib/adapter.mjs +32 -0
- package/lib/config.mjs +66 -0
- package/lib/docker.mjs +4 -0
- package/lib/files.mjs +2 -0
- package/lib/install.mjs +24 -0
- package/lib/release.mjs +13 -0
- package/lib/update.mjs +14 -0
- package/package.json +42 -4
- package/request.mjs +7 -0
- package/templates/seccomp.json +831 -0
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# TikTok Unified · 2.1.4
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Autor: D3iu · [deiu@resita.eu](mailto:deiu@resita.eu)** · Licență AGPL-3.0.
|
|
4
|
+
|
|
5
|
+
Quiz TikTok LIVE pentru mai mulți creatori, overlay vertical, transmisie RTMP/RTMPS și muzică YouTube, într-o aplicație Docker. Repository: [d3iu/tiktok-unified](https://github.com/d3iu/tiktok-unified).
|
|
6
|
+
|
|
7
|
+
## Instalare rapidă
|
|
8
|
+
|
|
9
|
+
Necesită Linux amd64, Node.js 24+, Docker Engine local și Compose 2.30+.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @d3iu/tiktok-quiz
|
|
13
|
+
tiktok-quiz install --dir "$HOME/tiktok-unified"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Alternativ: `npx @d3iu/tiktok-quiz install --dir "$HOME/tiktok-unified"`.
|
|
17
|
+
|
|
18
|
+
Asistentul întreabă portul (**8077** implicit), contul inițial, adresa publică și resursele. Folosește metadatele publice npm pentru versiune, fără acces la repository-ul GitHub privat. Descarcă imaginile Docker pregătite; nu compilează aplicația pe server. Datele rămân într-un volum persistent separat. Niciun cont, parolă, stream key sau profil Google al autorului nu este distribuit.
|
|
19
|
+
|
|
20
|
+
- [Instrucțiuni complete de instalare și administrare](https://github.com/d3iu/tiktok-unified/blob/main/docs/INSTALL.md)
|
|
21
|
+
- [Recuperare din backup](https://github.com/d3iu/tiktok-unified/blob/main/docs/RECOVERY.md)
|
|
22
|
+
- [Procesul de publicare a versiunilor](https://github.com/d3iu/tiktok-unified/blob/main/docs/RELEASE.md)
|
|
23
|
+
- [Modificări](https://github.com/d3iu/tiktok-unified/blob/main/CHANGELOG.md)
|
|
24
|
+
|
|
25
|
+
## Portainer și Synology
|
|
26
|
+
|
|
27
|
+
Poți configura aplicația direct prin Environment în manager, fără npm sau un `.env` obligatoriu. Sunt disponibile template-uri și arhive dedicate pentru Linux x86-64: [instrucțiuni Portainer/Synology](https://github.com/d3iu/tiktok-unified/blob/main/docs/MANAGERS.md).
|
|
28
|
+
|
|
29
|
+
## Actualizare
|
|
30
|
+
|
|
31
|
+
Din contul de administrator: **Actualizare → Actualizează aplicația**. Sau:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
tiktok-quiz update --dir "$HOME/tiktok-unified"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Oprește mai întâi jocurile, transmisiile și muzica tuturor conturilor. Actualizarea păstrează configurația, salvează un backup complet și verifică noua versiune; dacă pornirea eșuează, revine la imaginea și datele anterioare. Agentul de actualizare rulează separat, fără port public. Aplicația nu primește acces direct la Docker.
|
|
38
|
+
|
|
39
|
+
## Conturi și acces
|
|
40
|
+
|
|
41
|
+
Conturile, parolele, întrebările, scorurile, istoricul, setările, temele și SMTP provin din Quiz. Administratorul creează și suspendă creatorii din Creatori. Suspendarea oprește și procesele media ale contului.
|
|
42
|
+
|
|
43
|
+
În Creatori → Adresa publică a jocului există bifa **Restricționează accesul la adresa oficială a panoului**. Activată: modificările sunt acceptate de la adresa oficială; fără adresă configurată se folosește originea curentă. Dezactivată: accesul prin IP/port și alte domenii este permis, dar cererile de modificare trebuie să provină în continuare de la aceeași origine ca panoul și să includă antetul CSRF. Numai administratorul poate schimba opțiunea. La migrare, vechiul domeniu este păstrat privat în evidența migrării, iar adresa nouă poate fi configurată după accesarea portului 8077; nu se redirecționează către proiectul original.
|
|
44
|
+
|
|
45
|
+
## Studio Live
|
|
46
|
+
|
|
47
|
+
În Controlul jocului: Server RTMP/RTMPS deasupra cheii stream mascate, Salvează conexiunea, afișare temporară 15 secunde, **Pornește Transmisia** (verde), **Oprește Transmisia** (roșu), **Pornește Muzica**, **Pauză Muzică**, **Oprește Muzica**. Câmpul gol conservă cheia salvată; cheia nu intră în SSE, overlay, clasament sau răspunsurile obișnuite de configurare.
|
|
48
|
+
|
|
49
|
+
Transmisia deschide automat overlay-ul contului la `http://127.0.0.1:3000/overlay?token=…`. Regenerarea tokenului reîncarcă sursa locală. Linkurile externe overlay/clasament și utilizarea prin OBS rămân disponibile separat.
|
|
50
|
+
|
|
51
|
+
Pornirea transmisiei nu pornește jocul. Muzica configurată pornește la confirmarea progresului encoderului și este pornită din nou după recuperarea transmisiei. Dacă muzica eșuează, transmisia continuă, iar eroarea apare în panou. Butoanele separate permit reîncercarea muzicii. Oprirea transmisiei închide muzica și procesele media, anulează reconectarea și pune jocul LIVE în pauză, inclusiv în pregătire sau la afișarea soluției; reluarea jocului este manuală. Oprirea muzicii nu oprește transmisia.
|
|
52
|
+
|
|
53
|
+
## Broadcast
|
|
54
|
+
|
|
55
|
+
Secțiune disponibilă fiecărui cont: link YouTube, volum muzică, rezoluție, FPS, bitrate video/audio, preset encoder, interval cadre cheie, frecvență audio, volum mix, reconectare, expirare previzualizare și reluare opțională după restart. Serverul RTMP/RTMPS și cheia stream se configurează în Studio Live. Serverul se salvează și la confirmarea editării; Salvează conexiunea și Pornește Transmisia salvează ambele câmpuri împreună. Sursa overlay este automată, 1080 × 1920; ieșirea video este configurabilă.
|
|
56
|
+
|
|
57
|
+
Previzualizarea și muzica pot porni independent de RTMP. Capturile se opresc când pagina este ascunsă; după inactivitate browserul de previzualizare este eliberat. Modificările video în timpul transmisiei se aplică la următoarea pornire. Volumul muzicii se aplică imediat. Opțiunea de oprire a transmisiei la încheierea programată a jocului este separată și implicit dezactivată.
|
|
58
|
+
|
|
59
|
+
Animațiile sunt adaptate capturii la 30 FPS: mișcări cu amplitudine moderată, tranziții mai lungi, efecte ambientale păstrate între actualizări, canvas și derulări calculate după timpul real la maximum 30 actualizări/secundă. Cronometrul este interpolat local la același ritm; regulile și deadline-urile rămân pe server. Profilul vizual reduce recalcularea filtrelor și umbrelor animate în favoarea transformărilor și opacității. Preferința de mișcare redusă și dezactivarea animațiilor temei sunt respectate. Pentru transmisia la 30 FPS, setează și câmpul FPS din Broadcast la 30.
|
|
60
|
+
|
|
61
|
+
Răspunsurile și activitățile LIVE sunt trimise către panou și overlay în loturi scurte de maximum aproximativ 16 ms după procesarea evenimentului. Reacțiile noi pentru cadouri, inimă și urmăritori înlocuiesc imediat mesajul anterior, fără coadă de animații și fără fade-out înainte de afișare. Ajutorul 50/50 se aplică la primul pachet al cadoului; contabilizarea valorii unei serii rămâne la final, o singură dată. O reacție mai nouă poate înlocui mesajul 50/50, dar nu anulează ajutorul. Punctele pentru răspunsuri sunt acordate la închiderea întrebării. Latența livrării evenimentelor de către TikTok și întârzierea video a platformei rămân externe aplicației.
|
|
62
|
+
|
|
63
|
+
Administratorul controlează limita comună de sesiuni media simultane (implicit 2). Conturile au ecrane virtuale, servere audio, browsere și encodere separate. Conturile și setările de acces nu sunt duplicate într-un al doilea panou.
|
|
64
|
+
|
|
65
|
+
## YouTube
|
|
66
|
+
|
|
67
|
+
Conectarea opțională deschide un browser Google privat controlat din panou; profilul este separat per cont în `/app/data/youtube/<id>`. Din afara localhost este necesar HTTPS. Datele Google se introduc în pagina Google, nu sunt logate și nu apar pe overlay. Capturile și inputurile sunt legate de sesiunea care a deschis fereastra. Deconectarea închide muzica și elimină profilul acelui cont. Google poate solicita autentificare din nou sau refuza browserul/clipul. Migrarea unui profil nu garantează validitatea sesiunii Google.
|
|
68
|
+
|
|
69
|
+
## Date și securitate
|
|
70
|
+
|
|
71
|
+
SQLite, cheia de criptare SMTP și profilurile YouTube persistă în volumul dedicat. Setările Broadcast private sunt separate de setările publice ale jocului. Fiecare creator își folosește propriile conturi, linkuri și chei.
|
|
72
|
+
|
|
73
|
+
`.env`, datele, sesiunile, backupurile și cheile sunt excluse din Git, npm și contextul Docker. Imaginile includ numai sursele selectate explicit. Backupurile instalării conțin date sensibile: păstrează-le private. Nu folosi `docker compose down -v`.
|
|
74
|
+
|
|
75
|
+
## Dezvoltare și verificare
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm ci
|
|
79
|
+
npm test
|
|
80
|
+
cp .env.example .env
|
|
81
|
+
# Configurează administratorul înainte de pornire.
|
|
82
|
+
docker compose up -d --build --wait --wait-timeout 120
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Testele folosesc conturi sintetice și transport TikTok injectat; nu confirmă o transmisie publică reală. Imaginea include Node.js 24, SQLite, Chromium/Chrome, FFmpeg, Xvfb și PulseAudio. Vezi [instalarea din sursă](https://github.com/d3iu/tiktok-unified/blob/main/docs/INSTALL.md#instalare-din-sursă).
|
|
86
|
+
|
|
87
|
+
## Licență
|
|
88
|
+
|
|
89
|
+
AGPL-3.0, vezi LICENSE și THIRD_PARTY.md. Arhiva sursei corespunzătoare imaginii este disponibilă la `/source.tar.gz` după autentificare.
|
package/agent.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import http from 'node:http';import {readFile} from 'node:fs/promises';import {timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import {fileURLToPath} from 'node:url';import {resolve,join} from 'node:path';
|
|
3
|
+
import {latestRelease,newer} from './lib/release.mjs';import {readInstallation} from './lib/install.mjs';import {UpdateAdapter} from './lib/adapter.mjs';import {performUpdate} from './lib/update.mjs';import {atomicFile} from './lib/files.mjs';
|
|
4
|
+
export function authorized(header,token){const a=Buffer.from(String(header||'')),b=Buffer.from('Bearer '+token);return a.length===b.length&&timingSafeEqual(a,b);}
|
|
5
|
+
export async function createAgent({dir=process.env.INSTALL_DIR,token,releaseProvider=latestRelease,adapter}={}){
|
|
6
|
+
if(!dir)throw Error('INSTALL_DIR necesar.');token??=(await readFile(process.env.UPDATE_TOKEN_FILE||join(dir,'.update-token'),'utf8')).trim();adapter??=new UpdateAdapter(dir,token);
|
|
7
|
+
let job={running:false,message:'Pregătit.'},cache=null,checked=0;
|
|
8
|
+
try{job=JSON.parse(await readFile(join(dir,'update-status.json'),'utf8'));if(job.running)job={running:false,error:'Operație întreruptă; se verifică recuperarea.',message:'Recuperare…'};}catch{}
|
|
9
|
+
if(adapter.recover){try{if(await adapter.recover())job={running:false,message:'Versiunea anterioară a fost recuperată după o întrerupere.'};}catch{job={running:false,error:'Recuperarea necesită intervenție. Consultă documentația.',message:'Recuperare eșuată.'};}}
|
|
10
|
+
const save=()=>atomicFile(join(dir,'update-status.json'),JSON.stringify(job));await save();
|
|
11
|
+
async function release(force=false){if(force||!cache||Date.now()-checked>60000){cache=await releaseProvider();checked=Date.now();}return cache;}
|
|
12
|
+
const server=http.createServer(async(req,res)=>{
|
|
13
|
+
res.setHeader('Content-Type','application/json');res.setHeader('Cache-Control','no-store');const send=(status,data)=>{res.writeHead(status);res.end(JSON.stringify(data));};
|
|
14
|
+
if(!authorized(req.headers.authorization,token)){send(401,{error:'Acces neautorizat.'});return;}
|
|
15
|
+
try{
|
|
16
|
+
if(req.url==='/status'&&req.method==='GET'){const current=await readInstallation(dir);let latest=null,checkError='';try{latest=await release();}catch(e){checkError=e.message;}send(200,{installed:current.version,latest:latest?.version||null,available:!!latest&&newer(latest.version,current.version),notes:latest?.notes||'',url:latest?.url||'',checkError,job});return;}
|
|
17
|
+
if(['/update','/backup'].includes(req.url)&&req.method==='POST'){
|
|
18
|
+
if(job.running){send(409,{error:'O operație este deja în desfășurare.'});return;}
|
|
19
|
+
if(job.error?.includes('Recuperarea necesită')){send(409,{error:job.error});return;}
|
|
20
|
+
// Reserve immediately, before any asynchronous work or request can race.
|
|
21
|
+
job={running:true,message:'Se verifică instalarea…',startedAt:Date.now()};await save();
|
|
22
|
+
const backupOnly=req.url==='/backup';let target;
|
|
23
|
+
try{if(!backupOnly){target=await release(true);if(!newer(target.version,(await readInstallation(dir)).version))throw Error('Aplicația este deja la ultima versiune stabilă.');}await adapter.lock();await adapter.unlock();}catch(e){job={running:false,message:'Operație refuzată.',error:e.message};await save();send(409,{error:e.message});return;}
|
|
24
|
+
send(202,{accepted:true});
|
|
25
|
+
void performUpdate(adapter,target,{backupOnly,report:message=>{job.message=message;}}).then(async backup=>{job={running:false,message:backupOnly?'Backup finalizat.':'Actualizare finalizată.',backup:backup?.split('/').at(-1),finishedAt:Date.now()};await save();}).catch(async e=>{job={running:false,error:e.message,message:'Operația nu s-a finalizat.',finishedAt:Date.now()};await save();});return;
|
|
26
|
+
}
|
|
27
|
+
send(404,{error:'Rută inexistentă.'});
|
|
28
|
+
}catch{send(500,{error:'Agentul de actualizare nu poate procesa cererea.'});}
|
|
29
|
+
});return {server};
|
|
30
|
+
}
|
|
31
|
+
if(process.argv[1]===fileURLToPath(import.meta.url)){const {server}=await createAgent();server.listen(8090,'0.0.0.0',()=>console.log('Update agent ready'));}
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {createInterface} from 'node:readline/promises';import {Writable} from 'node:stream';import {readFile} from 'node:fs/promises';import {resolve,join} from 'node:path';
|
|
3
|
+
import {defaults,validateConfig} from './lib/config.mjs';import {latestRelease} from './lib/release.mjs';import {prerequisites,compose} from './lib/docker.mjs';import {createInstallation,readInstallation,existingConfig,writeConfig,checkPort} from './lib/install.mjs';
|
|
4
|
+
const args=process.argv.slice(2),command=args.shift()||'help';const options={};for(let i=0;i<args.length;i++){if(['--dir','--config'].includes(args[i])){if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Valoare lipsă pentru '+args[i]);options[args[i].slice(2)]=args[++i];}else if(args[i]==='--non-interactive')options.nonInteractive=true;else throw Error('Opțiune necunoscută: '+args[i]);}
|
|
5
|
+
const dir=resolve(options.dir||'tiktok-unified');
|
|
6
|
+
async function wizard(initial){
|
|
7
|
+
if(options.config){const input=JSON.parse(await readFile(resolve(options.config),'utf8'));return validateConfig({...initial,...input});}
|
|
8
|
+
if(options.nonInteractive||!process.stdin.isTTY)throw Error('Pentru instalare fără terminal folosește --config /cale/config.json.');
|
|
9
|
+
let muted=false;const out=new Writable({write(chunk,encoding,next){if(!muted)process.stdout.write(chunk,encoding);next();}});out.isTTY=true;out.columns=process.stdout.columns||80;const rl=createInterface({input:process.stdin,output:out,terminal:true});
|
|
10
|
+
async function ask(key,label,{secret=false}={}){const previous=initial[key];process.stdout.write(label+(previous?(secret?' [Enter = păstrează]':' ['+previous+']'):'')+': ');muted=secret;try{const value=await rl.question('');initial[key]=value||previous;}finally{muted=false;if(secret)process.stdout.write('\n');}}
|
|
11
|
+
try{
|
|
12
|
+
for(const [key,label,secret]of [['HOST_PORT','Port',false],['BIND_ADDRESS','Adresă bind (0.0.0.0 pentru acces din rețea)',false],['ADMIN_EMAIL','Email administrator inițial',false],['ADMIN_PASSWORD','Parolă administrator inițial (minimum 12 caractere)',true],['PUBLIC_URL','Adresă publică HTTP/HTTPS (opțional)',false],['COOKIE_SECURE','Cookie numai prin HTTPS (true/false)',false],['EULER_API_KEY','Cheie Euler (opțional)',true],['CPU_LIMIT','Limită CPU',false],['MEMORY_LIMIT','Limită memorie (ex. 3g)',false],['SHM_SIZE','Memorie partajată (ex. 1gb)',false],['MAX_ACTIVE_BROADCASTS','Număr maxim de transmisii simultane',false],['YOUTUBE_LOGIN_IDLE_SECONDS','Expirare conectare YouTube (secunde)',false],['PIDS_LIMIT','Limită procese',false]])await ask(key,label,{secret});
|
|
13
|
+
return validateConfig(initial);
|
|
14
|
+
}finally{rl.close();}
|
|
15
|
+
}
|
|
16
|
+
async function request(action){const result=await compose(dir,['exec','-T','updater','node','/opt/installer/request.mjs',action]);return JSON.parse(result);}
|
|
17
|
+
async function waitJob(){for(let i=0;i<900;i++){await new Promise(r=>setTimeout(r,2000));const s=await request('status');if(!s.job.running){if(s.job.error)throw Error(s.job.error);console.log(s.job.message+(s.job.backup?' Backup: backups/'+s.job.backup:''));return;}}throw Error('Operația continuă în fundal; verifică tiktok-quiz status.');}
|
|
18
|
+
async function main(){
|
|
19
|
+
if(['help','--help','-h'].includes(command)){console.log('TikTok Unified · D3iu <deiu@resita.eu>\n\nComenzi: install, configure, start, stop, restart, status, logs, backup, update\nOpțiuni: --dir /cale/instalare, --config /cale/config.json, --non-interactive\n\nExemple:\n npx @d3iu/tiktok-quiz install --dir ./tiktok-unified\n tiktok-quiz update --dir ./tiktok-unified\n\nNode >=24, Linux amd64, Docker local și Compose >=2.30 necesare.');return;}
|
|
20
|
+
if(command==='--version'){console.log(JSON.parse(await readFile(new URL('./package.json',import.meta.url),'utf8')).version);return;}
|
|
21
|
+
if(!['install','configure','start','stop','restart','status','logs','backup','update'].includes(command))throw Error('Comandă necunoscută. Folosește --help.');
|
|
22
|
+
await prerequisites();
|
|
23
|
+
if(command==='install'){
|
|
24
|
+
const config=await wizard({...defaults});await checkPort(config.HOST_PORT,config.BIND_ADDRESS);console.log('Se verifică ultima versiune stabilă…');const release=await latestRelease();await createInstallation(dir,config,release);await compose(dir,['pull']);await compose(dir,['up','-d','--wait','--wait-timeout','120'],{inherit:true});console.log('Instalare finalizată în '+dir+'\nPanou: '+(config.PUBLIC_URL||'http://ADRESA-SERVERULUI:'+config.HOST_PORT));return;
|
|
25
|
+
}
|
|
26
|
+
const installation=await readInstallation(dir);if(installation.schema!==1)throw Error('Instalare incompatibilă.');
|
|
27
|
+
if(command==='configure'){
|
|
28
|
+
// A maintenance lock prevents a creator from starting LIVE during reconfiguration.
|
|
29
|
+
const current=await existingConfig(dir),next=await wizard({...current});if(next.HOST_PORT!==current.HOST_PORT||next.BIND_ADDRESS!==current.BIND_ADDRESS)await checkPort(next.HOST_PORT,next.BIND_ADDRESS);
|
|
30
|
+
// Configuration changes use the same protected maintenance endpoint inside the agent.
|
|
31
|
+
await compose(dir,['exec','-T','updater','node','/opt/installer/configure.mjs','lock']);
|
|
32
|
+
try{await writeConfig(dir,next,installation);await compose(dir,['up','-d','--wait','--wait-timeout','120','quiz'],{inherit:true});console.log('Configurație aplicată. Conturile existente se gestionează din panou; datele bootstrap nu schimbă parolele lor.');}catch(e){await writeConfig(dir,current,installation);await compose(dir,['up','-d','quiz']);throw e;}finally{await compose(dir,['exec','-T','updater','node','/opt/installer/configure.mjs','unlock']).catch(()=>{});}return;
|
|
33
|
+
}
|
|
34
|
+
if(command==='status'){await compose(dir,['ps'],{inherit:true});try{const s=await request('status');console.log(JSON.stringify(s,null,2));}catch{console.log('Agentul nu este disponibil.');}return;}
|
|
35
|
+
if(command==='logs'){await compose(dir,['logs','--tail','100','quiz'],{inherit:true});return;}
|
|
36
|
+
if(command==='update'||command==='backup'){await request(command);console.log('Operație pornită.');await waitJob();return;}
|
|
37
|
+
if(command==='start'){await writeConfig(dir,validateConfig(await existingConfig(dir)),installation);await compose(dir,['up','-d','--wait','--wait-timeout','120'],{inherit:true});}
|
|
38
|
+
if(command==='stop')await compose(dir,['stop'],{inherit:true});
|
|
39
|
+
if(command==='restart')await compose(dir,['restart','quiz'],{inherit:true});
|
|
40
|
+
}
|
|
41
|
+
main().catch(e=>{console.error('Eroare: '+e.message);process.exitCode=1;});
|
package/configure.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import {UpdateAdapter} from './lib/adapter.mjs';import {readFile} from 'node:fs/promises';
|
|
2
|
+
const action=process.argv[2];if(!['lock','unlock'].includes(action))throw Error('Acțiune invalidă.');
|
|
3
|
+
const token=(await readFile(process.env.UPDATE_TOKEN_FILE,'utf8')).trim();const adapter=new UpdateAdapter(process.env.INSTALL_DIR,token);
|
|
4
|
+
try{await adapter[action]();}catch(e){console.error(e.message);process.exitCode=1;}
|
package/lib/adapter.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {readFile,mkdir,copyFile,rm,stat,chown,chmod} from 'node:fs/promises';import {join} from 'node:path';
|
|
2
|
+
import {compose,docker} from './docker.mjs';import {atomicFile} from './files.mjs';import {readInstallation,existingConfig,writeConfig} from './install.mjs';
|
|
3
|
+
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
|
4
|
+
export class UpdateAdapter{
|
|
5
|
+
constructor(dir,token){this.dir=dir;this.token=token;this.journal=join(dir,'.update-journal.json');}
|
|
6
|
+
async app(action){const r=await fetch('http://quiz:3000/api/internal/update',{method:action?'POST':'GET',headers:{Authorization:'Bearer '+this.token,'Content-Type':'application/json'},...(action?{body:JSON.stringify({action})}:{}),signal:AbortSignal.timeout(10000)});const data=await r.json();if(!r.ok)throw Error(data.error||'Aplicația refuză mentenanța.');return data;}
|
|
7
|
+
async writeLease(){await mkdir(join(this.dir,'.update-control'),{recursive:true,mode:0o755});await atomicFile(join(this.dir,'.update-control/maintenance.json'),JSON.stringify({until:Date.now()+90000}),0o644);}
|
|
8
|
+
async blockRecovery(){this.leaseActive=false;await this.renewing?.catch(()=>{});await atomicFile(join(this.dir,'.update-control/maintenance.json'),JSON.stringify({until:Number.MAX_SAFE_INTEGER,recoveryRequired:true}),0o644);}
|
|
9
|
+
async lock(){await this.app('lock');this.leaseActive=true;try{await this.writeLease();}catch(e){await this.unlock();throw e;}}
|
|
10
|
+
async unlock(){this.leaseActive=false;await this.renewing?.catch(()=>{});await rm(join(this.dir,'.update-control/maintenance.json'),{force:true});await this.app('unlock');}
|
|
11
|
+
async renew(){if(!this.leaseActive)return;this.renewing=(async()=>{await this.writeLease();if(this.leaseActive)await this.app('renew');})();await this.renewing;}
|
|
12
|
+
async pull(release){await docker(['pull',release.image]);const raw=await docker(['image','inspect',release.image,'--format','{{json .Config.Labels}}']);const labels=JSON.parse(raw);if(labels['org.opencontainers.image.version']!==release.version||labels['org.opencontainers.image.source']!=='https://github.com/d3iu/tiktok-unified')throw Error('Imaginea Docker nu corespunde release-ului.');}
|
|
13
|
+
async stop(){this.previous=await readInstallation(this.dir);await atomicFile(this.journal,JSON.stringify({previous:this.previous,backup:null}));await compose(this.dir,['stop','quiz']);}
|
|
14
|
+
async backup(){
|
|
15
|
+
const name=new Date().toISOString().replace(/[:.]/g,'-')+'-'+process.pid,dir=join(this.dir,'backups',name);await mkdir(dir,{mode:0o700});
|
|
16
|
+
for(const file of ['.env','.runtime.env','installation.json','compose.yaml','seccomp.json','.update-token'])await copyFile(join(this.dir,file),join(dir,file));
|
|
17
|
+
const volume=this.previous.project+'_data';if(!/^tiktok-unified-[a-f0-9]{8}_data$/.test(volume))throw Error('Volum invalid.');
|
|
18
|
+
await docker(['run','--rm','--network','none','--user','0','--entrypoint','tar','-v',volume+':/data:ro','-v',dir+':/backup',this.previous.image,'-czf','/backup/data.tar.gz','-C','/data','.']);
|
|
19
|
+
const owner=await stat(this.dir);for(const file of ['data.tar.gz','.env','.runtime.env','installation.json','compose.yaml','seccomp.json','.update-token']){await chmod(join(dir,file),0o600);if(process.getuid?.()===0)await chown(join(dir,file),owner.uid,owner.gid);}if(process.getuid?.()===0)await chown(dir,owner.uid,owner.gid);
|
|
20
|
+
this.backupDir=dir;await atomicFile(this.journal,JSON.stringify({previous:this.previous,backup:dir}));return dir;
|
|
21
|
+
}
|
|
22
|
+
async switchVersion(release){const next={...this.previous,version:release.version,image:release.image};await writeConfig(this.dir,await existingConfig(this.dir),next);await atomicFile(join(this.dir,'installation.json'),JSON.stringify(next,null,2)+'\n');}
|
|
23
|
+
async start(){await compose(this.dir,['up','-d','--no-deps','quiz']);}
|
|
24
|
+
async health(){for(let i=0;i<60;i++){try{const r=await fetch('http://quiz:3000/health',{signal:AbortSignal.timeout(2000)});if(r.ok&&(await r.json()).version===(await readInstallation(this.dir)).version){await rm(this.journal,{force:true});return;}}catch{}await delay(2000);}throw Error('Noua versiune nu a trecut verificarea health.');}
|
|
25
|
+
async rollback(snapshot){
|
|
26
|
+
await compose(this.dir,['stop','quiz']);
|
|
27
|
+
if(snapshot){const volume=this.previous.project+'_data';await docker(['run','--rm','--network','none','--user','0','--entrypoint','sh','-v',volume+':/data','-v',snapshot+':/backup:ro',this.previous.image,'-c','find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xzf /backup/data.tar.gz -C /data']);for(const file of ['.env','.runtime.env','installation.json'])await atomicFile(join(this.dir,file),await readFile(join(snapshot,file)));}
|
|
28
|
+
else if(this.previous){await writeConfig(this.dir,await existingConfig(this.dir),this.previous);await atomicFile(join(this.dir,'installation.json'),JSON.stringify(this.previous,null,2)+'\n');}
|
|
29
|
+
await this.start();await this.health();
|
|
30
|
+
}
|
|
31
|
+
async recover(){let j;try{j=JSON.parse(await readFile(this.journal,'utf8'));}catch(e){if(e.code==='ENOENT'){await rm(join(this.dir,'.update-control/maintenance.json'),{force:true});return false;}throw e;}this.previous=j.previous;this.leaseActive=true;await this.writeLease();const timer=setInterval(()=>{void this.renew().catch(()=>{});},10000);try{await this.rollback(j.backup);clearInterval(timer);await this.unlock().catch(()=>{});return true;}catch(e){clearInterval(timer);await this.blockRecovery();throw e;}}
|
|
32
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
export const defaults={HOST_PORT:'8077',BIND_ADDRESS:'0.0.0.0',ADMIN_EMAIL:'',ADMIN_PASSWORD:'',PUBLIC_URL:'',COOKIE_SECURE:'false',EULER_API_KEY:'',MAX_ACTIVE_BROADCASTS:'2',YOUTUBE_LOGIN_IDLE_SECONDS:'180',SHM_SIZE:'1gb',CPU_LIMIT:'4.0',MEMORY_LIMIT:'3g',PIDS_LIMIT:'1024'};
|
|
3
|
+
export function validateConfig(input){
|
|
4
|
+
const c={...defaults,...input};
|
|
5
|
+
for(const [key,value] of Object.entries(c)){if(!Object.hasOwn(defaults,key)||typeof value!=='string'||/[\r\n\0]/.test(value))throw Error('Valoare invalidă: '+key);}
|
|
6
|
+
for(const [key,min,max] of [['HOST_PORT',1,65535],['MAX_ACTIVE_BROADCASTS',1,20],['YOUTUBE_LOGIN_IDLE_SECONDS',30,3600],['PIDS_LIMIT',256,65536]])if(!/^\d+$/.test(c[key])||+c[key]<min||+c[key]>max)throw Error('Valoare invalidă: '+key);
|
|
7
|
+
if(!net.isIP(c.BIND_ADDRESS)||!/^\d+(\.\d+)?$/.test(c.CPU_LIMIT)||+c.CPU_LIMIT<1||+c.CPU_LIMIT>128)throw Error('Adresă sau limită CPU invalidă.');
|
|
8
|
+
for(const key of ['SHM_SIZE','MEMORY_LIMIT'])if(!/^[1-9]\d*(?:m|mb|g|gb)$/i.test(c[key]))throw Error('Memorie invalidă: '+key);
|
|
9
|
+
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(c.ADMIN_EMAIL)||c.ADMIN_PASSWORD.length<12||c.ADMIN_PASSWORD.length>256)throw Error('Email valid și parolă de 12–256 caractere necesare.');
|
|
10
|
+
if(c.PUBLIC_URL){const u=new URL(c.PUBLIC_URL);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('Adresa publică trebuie să fie o origine HTTP/HTTPS.');c.PUBLIC_URL=u.origin;}
|
|
11
|
+
if(!['true','false'].includes(c.COOKIE_SECURE))throw Error('COOKIE_SECURE invalid.');
|
|
12
|
+
return c;
|
|
13
|
+
}
|
|
14
|
+
export function encodeEnv(c){return Object.entries(c).map(([k,v])=>`${k}='${v.replaceAll('\\','\\\\').replaceAll("'","\\'")}'`).join('\n')+'\n';}
|
|
15
|
+
export function parseEnv(text){const c={};for(const line of text.split('\n')){const m=/^\s*([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);if(!m)continue;let value=m[2].trim();if(value.startsWith("'")&&value.endsWith("'"))value=value.slice(1,-1).replace(/\\(['\\])/g,'$1');else if(value.startsWith('"')&&value.endsWith('"')){try{value=JSON.parse(value);}catch{throw Error('Valoare .env invalidă: '+m[1]);}}else value=value.replace(/\s+#.*$/,'');c[m[1]]=value;}return c;}
|
|
16
|
+
export function composeFile(){return `name: \${PROJECT_NAME}
|
|
17
|
+
services:
|
|
18
|
+
quiz:
|
|
19
|
+
image: \${APP_IMAGE}
|
|
20
|
+
init: true
|
|
21
|
+
restart: unless-stopped
|
|
22
|
+
ports:
|
|
23
|
+
- "\${BIND_ADDRESS}:\${HOST_PORT}:3000"
|
|
24
|
+
env_file:
|
|
25
|
+
- path: .runtime.env
|
|
26
|
+
format: raw
|
|
27
|
+
environment:
|
|
28
|
+
NODE_ENV: production
|
|
29
|
+
PORT: "3000"
|
|
30
|
+
DB_PATH: /app/data/quiz.sqlite
|
|
31
|
+
UPDATE_AGENT_URL: http://updater:8090
|
|
32
|
+
UPDATE_TOKEN_FILE: /run/secrets/update-token
|
|
33
|
+
UPDATE_MAINTENANCE_FILE: /run/update-control/maintenance.json
|
|
34
|
+
volumes:
|
|
35
|
+
- quiz_data:/app/data
|
|
36
|
+
- ./.update-token:/run/secrets/update-token:ro
|
|
37
|
+
- ./.update-control:/run/update-control:ro
|
|
38
|
+
shm_size: \${SHM_SIZE}
|
|
39
|
+
cpus: \${CPU_LIMIT}
|
|
40
|
+
mem_limit: \${MEMORY_LIMIT}
|
|
41
|
+
pids_limit: \${PIDS_LIMIT}
|
|
42
|
+
security_opt:
|
|
43
|
+
- seccomp=./seccomp.json
|
|
44
|
+
stop_grace_period: 45s
|
|
45
|
+
logging:
|
|
46
|
+
driver: json-file
|
|
47
|
+
options: { max-size: "10m", max-file: "3" }
|
|
48
|
+
updater:
|
|
49
|
+
image: \${UPDATER_IMAGE}
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
working_dir: \${INSTALL_DIR}
|
|
52
|
+
environment:
|
|
53
|
+
INSTALL_DIR: \${INSTALL_DIR}
|
|
54
|
+
UPDATE_TOKEN_FILE: \${INSTALL_DIR}/.update-token
|
|
55
|
+
volumes:
|
|
56
|
+
- /var/run/docker.sock:/var/run/docker.sock
|
|
57
|
+
- \${INSTALL_DIR}:\${INSTALL_DIR}
|
|
58
|
+
security_opt:
|
|
59
|
+
- no-new-privileges:true
|
|
60
|
+
logging:
|
|
61
|
+
driver: json-file
|
|
62
|
+
options: { max-size: "10m", max-file: "3" }
|
|
63
|
+
volumes:
|
|
64
|
+
quiz_data:
|
|
65
|
+
name: \${PROJECT_NAME}_data
|
|
66
|
+
`;}
|
package/lib/docker.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
export function docker(args,{cwd,inherit=false,timeout=600000}={}){return new Promise((resolve,reject)=>{const child=spawn('docker',args,{cwd,stdio:inherit?'inherit':['ignore','pipe','pipe']});let output='';if(!inherit){child.stdout.on('data',x=>{output+=x;if(output.length>2000000)child.kill();});child.stderr.on('data',()=>{});}const timer=setTimeout(()=>child.kill('SIGKILL'),timeout);child.on('error',()=>{clearTimeout(timer);reject(Error('Docker nu este disponibil. Instalează Docker Engine și Compose v2.'));});child.on('close',code=>{clearTimeout(timer);if(code===0)resolve(output.trim());else reject(Error('Comanda Docker a eșuat: '+args.slice(0,2).join(' ')+'. Verifică Docker și starea serviciilor.'));});});}
|
|
3
|
+
export function compose(dir,args,options={}){return docker(['compose','--project-directory',dir,'--env-file',dir+'/.env','-f',dir+'/compose.yaml',...args],{cwd:dir,...options});}
|
|
4
|
+
export async function prerequisites(){if(process.platform!=='linux'||process.arch!=='x64')throw Error('Această versiune necesită Linux amd64 (Chrome pentru transmisie).');await docker(['info']);const version=await docker(['compose','version','--short']);const [major,minor]=version.replace(/^v/,'').split('.').map(Number);if(major<2||(major===2&&minor<30))throw Error('Este necesar Docker Compose >=2.30 (env_file raw).');const context=await docker(['context','inspect','--format','{{.Endpoints.docker.Host}}']);if(context!=='unix:///var/run/docker.sock'||process.env.DOCKER_HOST&&process.env.DOCKER_HOST!=='unix:///var/run/docker.sock')throw Error('Este necesar Docker local pe /var/run/docker.sock; Docker remote/rootless nu este acceptat.');}
|
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import {writeFile,rename,stat,chown,chmod} from 'node:fs/promises';
|
|
2
|
+
export async function atomicFile(path,content,mode=0o600){let owner;try{owner=await stat(path);}catch{}const temp=path+'.tmp-'+process.pid;await writeFile(temp,content,{mode});await chmod(temp,mode);if(owner&&process.getuid?.()===0)await chown(temp,owner.uid,owner.gid);await rename(temp,path);}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {mkdir,readFile,writeFile,copyFile,access} from 'node:fs/promises';
|
|
2
|
+
import {resolve,dirname,join} from 'node:path';import {fileURLToPath} from 'node:url';import {randomBytes} from 'node:crypto';import net from 'node:net';
|
|
3
|
+
import {defaults,validateConfig,encodeEnv,parseEnv,composeFile} from './config.mjs';import {atomicFile} from './files.mjs';
|
|
4
|
+
const root=resolve(dirname(fileURLToPath(import.meta.url)),'..');
|
|
5
|
+
export async function exists(path){try{await access(path);return true;}catch{return false;}}
|
|
6
|
+
export async function checkPort(port,host){await new Promise((ok,no)=>{const server=net.createServer();server.once('error',()=>no(Error('Portul ales nu este disponibil.')));server.listen(Number(port),host,()=>server.close(ok));});}
|
|
7
|
+
export async function readInstallation(dir){return JSON.parse(await readFile(join(dir,'installation.json'),'utf8'));}
|
|
8
|
+
export async function writeConfig(dir,config,installation){
|
|
9
|
+
const env={...config,INSTALL_DIR:dir,PROJECT_NAME:installation.project,APP_IMAGE:installation.image,UPDATER_IMAGE:installation.updaterImage};
|
|
10
|
+
await atomicFile(join(dir,'.env'),encodeEnv(env));
|
|
11
|
+
await atomicFile(join(dir,'.runtime.env'),Object.entries(config).map(([k,v])=>k+'='+v).join('\n')+'\n');
|
|
12
|
+
}
|
|
13
|
+
export async function createInstallation(dir,input,release){
|
|
14
|
+
dir=resolve(dir);if(!/^\/[A-Za-z0-9_./-]+$/.test(dir)||dir==='/')throw Error('Directorul trebuie să aibă o cale absolută fără spații sau caractere speciale.');
|
|
15
|
+
if(await exists(dir)){const {readdir}=await import('node:fs/promises');if((await readdir(dir)).length)throw Error('Directorul există și conține fișiere. Folosește configure pentru o instalare existentă.');}
|
|
16
|
+
const config=validateConfig(input);await mkdir(dir,{recursive:true,mode:0o700});
|
|
17
|
+
const installation={schema:1,project:'tiktok-unified-'+randomBytes(4).toString('hex'),version:release.version,image:release.image,updaterImage:release.updaterImage};
|
|
18
|
+
await writeFile(join(dir,'installation.json'),JSON.stringify(installation,null,2)+'\n',{mode:0o600,flag:'wx'});
|
|
19
|
+
// Readable inside the non-root application; parent directory is private on the host.
|
|
20
|
+
await writeFile(join(dir,'.update-token'),randomBytes(32).toString('hex'),{mode:0o644,flag:'wx'});
|
|
21
|
+
await writeConfig(dir,config,installation);await writeFile(join(dir,'compose.yaml'),composeFile(),{mode:0o600});await copyFile(join(root,'templates/seccomp.json'),join(dir,'seccomp.json'));
|
|
22
|
+
await mkdir(join(dir,'.update-control'),{mode:0o755});await mkdir(join(dir,'backups'),{mode:0o700});return installation;
|
|
23
|
+
}
|
|
24
|
+
export async function existingConfig(dir){const all=parseEnv(await readFile(join(dir,'.env'),'utf8'));return Object.fromEntries(Object.keys(defaults).map(k=>[k,all[k]??defaults[k]]));}
|
package/lib/release.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const repository='d3iu/tiktok-unified';
|
|
2
|
+
export const imageBase='ghcr.io/d3iu/tiktok-unified';
|
|
3
|
+
export function validateManifest(m,tag){
|
|
4
|
+
if(!m||m.schema!==1||!/^\d+\.\d+\.\d+$/.test(m.version)||tag!=='v'+m.version||m.image!==imageBase+':'+m.version||m.updaterImage!==imageBase+'-updater:'+m.version)throw Error('Manifest de actualizare invalid.');return m;
|
|
5
|
+
}
|
|
6
|
+
export function newer(a,b){const aa=a.split('.').map(Number),bb=b.split('.').map(Number);for(let i=0;i<3;i++){if(aa[i]!==bb[i])return aa[i]>bb[i];}return false;}
|
|
7
|
+
export async function latestRelease({fetcher=fetch}={}){
|
|
8
|
+
const r=await fetcher('https://registry.npmjs.org/@d3iu%2ftiktok-quiz/latest',{headers:{Accept:'application/json'},signal:AbortSignal.timeout(20000),redirect:'error'});
|
|
9
|
+
if(!r.ok)throw Error('Ultima versiune npm nu este disponibilă ('+r.status+').');const pkg=await r.json();
|
|
10
|
+
if(pkg.name!=='@d3iu/tiktok-quiz'||pkg.version!==pkg.tiktokUnified?.version)throw Error('Metadate npm invalide.');
|
|
11
|
+
const manifest=validateManifest(pkg.tiktokUnified,'v'+pkg.version);
|
|
12
|
+
return {...manifest,notes:String(manifest.notes||'').slice(0,20000),url:'https://www.npmjs.com/package/@d3iu/tiktok-quiz/v/'+pkg.version};
|
|
13
|
+
}
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function performUpdate(adapter,release,{backupOnly=false,report=()=>{}}={}){
|
|
2
|
+
await adapter.lock();const heartbeat=adapter.renew?setInterval(()=>{void adapter.renew().catch(()=>{});},10000):null;heartbeat?.unref();let stopped=false,snapshot=null,recoveryFailed=false;
|
|
3
|
+
try{
|
|
4
|
+
if(!backupOnly){report('Se descarcă imaginea…');await adapter.pull(release);}
|
|
5
|
+
report('Se oprește aplicația pentru backup…');stopped=true;await adapter.stop();
|
|
6
|
+
report('Se salvează datele și configurația…');snapshot=await adapter.backup();
|
|
7
|
+
if(!backupOnly){report('Se aplică versiunea nouă…');await adapter.switchVersion(release);}
|
|
8
|
+
await adapter.start();report('Se verifică funcționarea…');await adapter.health();
|
|
9
|
+
report(backupOnly?'Backup finalizat.':'Actualizare finalizată.');return snapshot;
|
|
10
|
+
}catch(error){
|
|
11
|
+
if(stopped){report('Se revine la versiunea anterioară…');try{await adapter.rollback(snapshot);}catch{recoveryFailed=true;clearInterval(heartbeat);await adapter.blockRecovery?.();throw Error('Recuperarea automată a eșuat. Păstrează backupul și rulează tiktok-quiz status/logs; consultă instrucțiunile de recuperare.');}}
|
|
12
|
+
throw error;
|
|
13
|
+
}finally{clearInterval(heartbeat);if(!recoveryFailed)await adapter.unlock().catch(()=>{});}
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3iu/tiktok-quiz",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.1.4",
|
|
4
|
+
"description": "Instalare și administrare TikTok Unified prin Docker",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tiktok-quiz": "bin.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=24"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin.mjs",
|
|
14
|
+
"agent.mjs",
|
|
15
|
+
"request.mjs",
|
|
16
|
+
"lib/",
|
|
17
|
+
"templates/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"configure.mjs"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/*.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"license": "AGPL-3.0",
|
|
26
|
+
"author": "D3iu <deiu@resita.eu>",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/d3iu/tiktok-unified.git",
|
|
30
|
+
"directory": "cli"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/d3iu/tiktok-unified#readme",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": false
|
|
36
|
+
},
|
|
37
|
+
"tiktokUnified": {
|
|
38
|
+
"schema": 1,
|
|
39
|
+
"version": "2.1.4",
|
|
40
|
+
"image": "ghcr.io/d3iu/tiktok-unified:2.1.4",
|
|
41
|
+
"updaterImage": "ghcr.io/d3iu/tiktok-unified-updater:2.1.4",
|
|
42
|
+
"notes": "# Modificări\n\n## 2.1.4\n\n- Resolve public installation and updates through npm with private GitHub source\n\n## 2.1.3\n\n- Document authenticated access to private deployment repository\n\n## 2.1.2\n\n- Add environment-based Portainer and Synology DS1825+ deployments\n\n## 2.1.1\n\n- Publish a new stable release automatically after each verified main change\n\n## 2.1.0\n\n- Instalare interactivă prin `@d3iu/tiktok-quiz`, cu port, cont inițial și limite Docker configurabile.\n- Administrare prin terminal: configurare, pornire, oprire, stare, loguri, backup și actualizare.\n- Meniu Actualizare pentru administrator, bazat pe GitHub Releases și imagini GHCR.\n- Backup complet și revenire automată după o actualizare eșuată; blocare în timpul activităților LIVE.\n- Repository public nou și distribuție fără datele private ale instalării autorului.\n- Include funcțiile Quiz, broadcast RTMP/RTMPS, muzică, conturi separate și animații la 30 FPS din versiunea unificată 2.0.0.\n"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/request.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
export async function agentRequest(action='status'){
|
|
3
|
+
if(!['status','update','backup'].includes(action))throw Error('Acțiune invalidă.');
|
|
4
|
+
const token=(await readFile(process.env.UPDATE_TOKEN_FILE,'utf8')).trim();
|
|
5
|
+
const response=await fetch('http://127.0.0.1:8090/'+action,{method:action==='status'?'GET':'POST',headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(60000)});const data=await response.json();if(!response.ok)throw Error(data.error||'Cererea a eșuat.');return data;
|
|
6
|
+
}
|
|
7
|
+
try{console.log(JSON.stringify(await agentRequest(process.argv[2])));}catch(e){console.error(e.message);process.exitCode=1;}
|