My Maintenance · 0.6.44 · Windows & Android
Seadista oma Cloudflare’i sünkroonimine
Piltidega samm-sammuline juhend My Maintenance’i versioonile 0.6.44 Windowsis ja Androidis. Pilv on valikuline; äpp ja kohalikud varukoopiad töötavad ka ilma selleta. Piltidel on Cloudflare’i ingliskeelne liides, kontoandmed on välja lõigatud. Näidisnimed võivad sinu omadest erineda.
Sammud
01 / 11
Aktiveeri R2 ja kontrolli hindu
Logi Cloudflare’i sisse ning ava Storage & databases → R2 Object Storage. Vajadusel aktiveeri R2 tellimus ja vaata maksekinnituse tingimused ise üle. R2-l on igakuine tasuta kasutusmaht, kuid aktiveerimine võib nõuda makseandmeid ja piiri ületav kasutus võib olla tasuline. Kontrolli R2 ning Workersi kehtivaid hindu ja seadista kasutusmahu teavitused. My Maintenance on tasuta.
02 / 11
Loo privaatne konteiner
Vali R2 vaates Create bucket. Näidisnimi on my-maintenance-private. Vali Standard ning oma andmetele sobiv piirkond või jurisdiktsioon. Vajuta Create bucket. Hoia avalik ligipääs väljas: ära luba r2.dev avalikku aadressi ega lisa avalikku domeeni. Sama konteineri valid 5. sammus.

03 / 11
Loo Worker
Ava Compute → Workers & Pages → Create application ning vali Start with Hello World! Sisesta kordumatu nimi, näiteks my-maintenance-sync, ja vajuta Deploy. Jäta Protect with Cloudflare Access välja: äpp kasutab oma pääsuvõtit. Esialgne teenus ei sisalda veel sünkroonimise koodi.


04 / 11
Lisa My Maintenance’i kood
Laadi allolevalt lingilt Worker-kood alla. Ava loodud Worker ja vali Edit code. Ava worker.js, vali kogu näidiskood ning asenda see allalaaditud faili täieliku sisuga. Ära lisa võtit koodi. Vajuta Deploy. Tavalises brauseri eelvaates võib näha Unauthorized, sest seal ei saadeta äpi pääsuvõtit; see on ootuspärane.
Näita Worker-koodi
// Deploy into YOUR Cloudflare account. The R2 bucket must remain private.
const STATE='maintenance-state-v1.json';
const TABLES=['object_types','properties','maintenance_plans','spare_parts','maintenance_history','attachments'];
const PURCHASE_TABLES=['purchase_categories','purchased_products','purchase_attachments'];
const STATE_LIMIT=16*1024*1024,FILE_LIMIT=50*1024*1024;
const reply=(message,status=400)=>new Response(message,{status,headers:{'Cache-Control':'no-store','Content-Type':'text/plain; charset=utf-8'}});
async function authorized(request,secret){
if(typeof secret!=='string'||secret.length<32)return false;
const supplied=request.headers.get('Authorization')||'';
if(!supplied.startsWith('Bearer ')||supplied.length>1024)return false;
const hash=async s=>new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)));
const [a,b]=await Promise.all([hash(supplied.slice(7)),hash(secret)]);let different=0;for(let i=0;i<32;i++)different|=a[i]^b[i];return different===0;
}
async function limitedText(request,limit){
const reader=request.body?.getReader();if(!reader)return '';
let total=0;const chunks=[];
try{while(true){const {done,value}=await reader.read();if(done)break;total+=value.length;if(total>limit){await reader.cancel();throw new Error('too-large')}chunks.push(value);}}finally{reader.releaseLock();}
const bytes=new Uint8Array(total);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return new TextDecoder('utf-8',{fatal:true}).decode(bytes);
}
function conditional(headers){
const match=headers.get('If-Match'),none=headers.get('If-None-Match');
if(none==='*'&&!match)return true;
return !!match&&!none&&/^"[^"\r\n]+"$/.test(match);
}
export default {
async fetch(request,env){
if(!await authorized(request,env.SYNC_TOKEN))return reply('Unauthorized',401);
const url=new URL(request.url),name=url.pathname.slice(1),isState=name===STATE;
const blob=/^blob-([a-f0-9]{64})\.bin$/.exec(name);
const probe=/^sync-probe-[a-f0-9]{64}\.json$/.test(name);
if(url.search||(!isState&&!blob&&!probe))return reply('Not found',404);
if(probe&&request.method==='DELETE'){await env.MAINTENANCE.delete(name);return new Response(null,{status:204});}
if(!['GET','HEAD','PUT'].includes(request.method))return reply('Method not allowed',405);
if(request.method==='GET'||request.method==='HEAD'){
const object=request.method==='HEAD'?await env.MAINTENANCE.head(name):await env.MAINTENANCE.get(name);
if(!object)return reply('Not found',404);
const headers=new Headers({'ETag':object.httpEtag,'Content-Length':String(object.size),'Cache-Control':'no-store','Content-Type':isState?'application/json':'application/octet-stream','X-Content-Type-Options':'nosniff'});
return new Response(request.method==='HEAD'?null:object.body,{headers});
}
if(!conditional(request.headers))return reply('A strong If-Match or If-None-Match: * is required',428);
if(blob&&request.headers.get('If-None-Match')!=='*')return reply('Attachment objects are immutable',428);
const length=request.headers.get('Content-Length');
if(length===null||!/^\d+$/.test(length))return reply('Content-Length required',411);
if(Number(length)>(isState?STATE_LIMIT:probe?1024:FILE_LIMIT))return reply('Too large',413);
let body=request.body;
if(isState){
try {const text=await limitedText(request,STATE_LIMIT),doc=JSON.parse(text);
const tables=doc.version===2?[...TABLES,...PURCHASE_TABLES]:TABLES;
if(doc?.format!=='my-maintenance-sync'||![1,2].includes(doc.version)||!Array.isArray(doc.devices)||!doc.tables||Object.keys(doc.tables).sort().join()!==[...tables].sort().join()||tables.some(t=>!Array.isArray(doc.tables[t])))return reply('Invalid maintenance document',422);
body=text;
}catch(e){return reply(e.message==='too-large'?'Too large':'Invalid JSON',e.message==='too-large'?413:422);}
}else if(probe){try{body=await limitedText(request,1024);}catch(_){return reply('Too large',413);}}
else{
if(!body)return reply('Attachment body required',400);
// Keep the original HTTP request body: R2 requires a stream with known length.
// Cloudflare's HTTP runtime enforces Content-Length; R2 independently verifies SHA-256.
}
try {
const object=await env.MAINTENANCE.put(name,body,{onlyIf:request.headers,httpMetadata:{contentType:isState?'application/json':'application/octet-stream'},...(blob?{sha256:blob[1]}:{})});
if(!object)return reply('Precondition failed',412);
return new Response(null,{status:204,headers:{ETag:object.httpEtag,'Cache-Control':'no-store'}});
} catch (_) {return reply('Storage write failed; no success was acknowledged',502);}
}
};

05 / 11
Seo privaatne konteiner
Ava Workeri Settings → Bindings → Add binding. Vali R2 Bucket ja Add Binding. Variable name peab olema täpselt MAINTENANCE, suurte tähtedega. Vali 2. sammus loodud privaatne konteiner ja vajuta Deploy. Korduva nime vea korral muuda olemasolevat MAINTENANCE seost. Pildil on testkonteiner; vali enda oma.


06 / 11
Loo ja salvesta pääsuvõti
Genereeri vähemalt 32 märgi pikkune tugev juhuslik võti või kasuta allolevat kohalikku generaatorit. Hoia võti paroolihalduris. Vali Workeri Settings → Runtime variables and secrets → Add variable. Type on Secret ja Name on SYNC_TOKEN. Kleebi võti väljale Value ning vajuta Deploy. Hiljem Cloudflare võtit enam ei näita. See on äpi pääsuvõti, mitte Cloudflare’i konto API-pääsutunnus.


07 / 11
Kopeeri Workeri aadress
Ava Workeri Overview ja kopeeri HTTPS workers.dev aadress, näiteks https://my-maintenance-sync.your-subdomain.workers.dev/. Kasuta juuraadressi ilma failitee, päringu või Cloudflare’i juhtpaneeli aadressita. Ilma võtmeta avatud brauseris peab vastuseks olema Unauthorized. Ära avalda võtit koos aadressiga.
08 / 11
Ühenda esimene seade
Ava My Maintenance’is Seaded → Valikuline pilvesünkroniseerimine. Vali Cloudflare ning sisesta Workeri HTTPS-aadress ja SYNC_TOKEN võti. Märgi nõusolek, vajuta Salvesta ühendus ja oota kinnitust. Vajuta Kontrolli ühendust. Salvestamine või kontrollimine kirjeid veel ei ühenda. Kontroll kinnitab ligipääsu; sünkroonimine kontrollib ka turvalist kirjutamist.

09 / 11
Vali andmed ja sünkrooni
Vali Ainult Hoolduse andmebaas või Koos „Kust ostetud” andmebaasiga, et kaasata mõlema mooduli kategooriad, tooted, fotod ja dokumendid. Vajuta Sünkrooni nüüd ning oota tulemust. Vastuolude korral võrdle mõlemat poolt ja vali teadlikult säilitatavad kirjed. Kustutamised kanduvad samuti üle. Asukohameeldetuletused, load, varundusgraafik ja ühendusvõtmed jäävad seadmekohaseks.
10 / 11
Ühenda ja kontrolli teist seadet
Sisesta teises seadmes sama Workeri aadress ja võti, vali sama andmete ulatus ning vajuta Sünkrooni nüüd. Kui mõlemas oli eraldi muudatusi, sünkrooni esimest veel kord. Kontrolli mõlemas näidisobjekti, hooldusplaani, toodet, fotot ja dokumenti. Ainult Hoolduse valik jätab ostuandmed muutmata. Hoia mõlemad äpid uusimal ühilduval versioonil.
11 / 11
Säilita sõltumatud varukoopiad
Pilvesünkroonimine ei asenda varukoopiat: kustutamine võib jõuda igasse seadmesse. Hoia automaatseid kohalikke varukoopiaid ning kopeeri olulised koopiad teise turvalisse kohta. Pilve väljalülitamisel jäävad kohalikud andmed alles. Ühenduse eemaldamine kustutab sellest seadmest võtme, mitte pilvefailid. Lekkinud võtme tühistamiseks muuda Cloudflare’is SYNC_TOKEN ja uuenda kõiki seadmeid.
Kui midagi ei õnnestu
Unauthorized / 401
Kontrolli, et äpi võti ja Workeri SYNC_TOKEN kattuvad täpselt ning kõrval pole tühikuid. Type peab olema Secret. Avalda muudatus Deploy-nupuga. Tavalises võtmeta brauseris on Unauthorized ootuspärane.
Puuduv seos või serveriviga
Kontrolli nime MAINTENANCE, valitud privaatset R2-konteinerit, täielikku Worker-koodi ja viimast avaldamist. D1 andmebaasi vaja ei ole.
Ühenduse kontroll õnnestub, sünkroonimine mitte
Loe äpi täielikku veateadet. Kontrolli R2 kättesaadavust ja kasutuspiire, manuste mahtu ning äppide ühilduvust. Ühenduse kontroll ei kinnita, et hilisem kirjutamine õnnestub. Säilita veateade ja varukoopia; ära tühjenda äpi andmeid.
Foto või dokument puudub
Ostumanuste jaoks peab valitud olema ka „Kust ostetud” andmebaas. Ava originaal saatvas seadmes. Sünkroonimine ei saa puuduvat faili taastada. Ühe faili pärast ära kustuta tervet kirjet.
Cloudflare’i ametlikud juhendid ja kehtivad hinnad
Pildid on tehtud Cloudflare’i juhtpaneelis 25.–26. oktoobril 2026. Cloudflare võib siltide nimetusi ja asukohti muuta. Vajadusel järgi siin toodud väljade nimesid ja väärtusi.