My Maintenance · 0.6.44 · Windows & Android
Ota oma Cloudflare-synkronointi käyttöön
Kuvallinen vaiheittainen ohje My Maintenance 0.6.44:lle Windowsissa ja Androidissa. Pilvi on valinnainen; sovellus ja paikalliset varmuuskopiot toimivat ilman sitä. Kuvissa on englanninkielinen Cloudflare ilman tilitietoja. Nimet ovat esimerkkejä.
Vaiheet
01 / 11
Aktivoi R2 ja tarkista hinnat
Kirjaudu sisään ja avaa Storage & databases → R2 Object Storage. Aktivoi R2 tarvittaessa ja tarkista maksuehdot itse. R2 sisältää kuukausittaisen maksuttoman käyttömäärän; aktivointi voi vaatia maksutiedot ja ylimenevä käyttö voi maksaa. Tarkista R2:n ja Workersin nykyhinnat sekä käyttöilmoitukset. My Maintenance on maksuton.
02 / 11
Luo yksityinen säiliö
Valitse R2:ssa Create bucket, esimerkiksi nimellä my-maintenance-private. Valitse Standard ja tiedoillesi sopiva alue tai lainkäyttöalue, sitten Create bucket. Pidä julkinen käyttö poissa käytöstä: älä ota r2.dev-osoitetta tai julkista verkkotunnusta käyttöön. Valitset saman säiliön vaiheessa 5.

03 / 11
Luo Worker
Avaa Compute → Workers & Pages → Create application → Start with Hello World! Anna yksilöllinen nimi, kuten my-maintenance-sync, ja valitse Deploy. Pidä Protect with Cloudflare Access pois päältä; sovellus käyttää omaa avainta. Aloituspalvelu ei vielä sisällä synkronointikoodia.


04 / 11
Lisää My Maintenancen koodi
Lataa Worker-koodi alta. Avaa Worker → Edit code → worker.js. Korvaa koko aloituskoodi ladatun tiedoston täydellä sisällöllä. Älä lisää avainta koodiin. Valitse Deploy. Unauthorized-ilmoitus esikatselussa ilman avainta on odotettu.
Näytä 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
Yhdistä yksityinen säiliö
Avaa Settings → Bindings → Add binding. Valitse R2 Bucket → Add Binding. Variable name on täsmälleen MAINTENANCE. Valitse vaiheen 2 yksityinen säiliö ja Deploy. Jos nimi on jo käytössä, muokkaa olemassa olevaa sidosta. Kuvan säiliö on testisäiliö; valitse omasi.


06 / 11
Luo ja tallenna yksityinen avain
Luo vahva satunnainen vähintään 32 merkin avain tai käytä paikallista generaattoria. Säilytä avain salasananhallinnassa. Avaa Settings → Runtime variables and secrets → Add variable: Type = Secret, Name = SYNC_TOKEN ja Value = avaimesi. Valitse Deploy. Cloudflare ei näytä arvoa enää tallennuksen jälkeen. Kyseessä ei ole Cloudflare-tilin API-tunniste.


07 / 11
Kopioi Workerin osoite
Kopioi Overview-sivulta HTTPS workers.dev -osoite, esimerkiksi https://my-maintenance-sync.your-subdomain.workers.dev/. Käytä juuriosoitetta ilman tiedostopolkua, kyselyä tai hallintapaneelin osoitetta. Selain ilman avainta saa Unauthorized-vastauksen. Älä julkaise avainta osoitteen yhteydessä.
08 / 11
Yhdistä ensimmäinen laite
Avaa My Maintenancessa Asetukset → Valinnainen pilvisynkronointi ja valitse Cloudflare. Anna HTTPS-osoite ja SYNC_TOKEN-avain. Hyväksy tietojen lähettäminen, tallenna yhteys ja odota vahvistusta. Tarkista yhteys. Tallennus tai tarkistus ei vielä yhdistä tietueita; synkronointi tarkistaa myös turvallisen kirjoittamisen.

09 / 11
Valitse tiedot ja synkronoi
Valitse vain huoltotietokanta tai lisäksi Ostopaikka, jolloin molempien moduulien kategoriat, tuotteet, kuvat ja asiakirjat sisältyvät. Valitse Synkronoi nyt ja odota tulosta. Vertaa ristiriidoissa molempia puolia ja päätä säilytettävät tietueet. Poistotkin siirtyvät. Sijaintimuistutukset, oikeudet, varmuuskopiointi ja avaimet pysyvät laitekohtaisina.
10 / 11
Yhdistä ja tarkista toinen laite
Anna toisessa laitteessa sama osoite ja avain. Valitse sama laajuus ja synkronoi. Jos molemmissa oli muutoksia, synkronoi ensimmäinen uudelleen. Tarkista kummassakin kohde, suunnitelma, tuote, kuva ja asiakirja. Vain huolto ei muuta ostotietoja. Käytä ajantasaisia yhteensopivia versioita.
11 / 11
Säilytä erilliset varmuuskopiot
Synkronointi ei ole varmuuskopio: poisto voi siirtyä kaikkiin laitteisiin. Säilytä paikalliset varmuuskopiot ja toinen turvallinen kopio. Pilven poistaminen käytöstä säilyttää paikalliset tiedot. Yhteyden poistaminen poistaa avaimen tästä laitteesta, ei pilvitiedostoja. Vaihda vuotanut SYNC_TOKEN ja päivitä kaikki laitteet.
Vianmääritys
Unauthorized / 401
Sovelluksen avaimen on vastattava SYNC_TOKEN-arvoa täsmälleen ilman lisävälilyöntejä. Tyypin on oltava Secret; julkaise Deploy-painikkeella. Selaimessa ilman avainta Unauthorized on normaali.
Puuttuva sidos tai palvelinvirhe
Tarkista MAINTENANCE, yksityinen säiliö, koko koodi ja viimeisin julkaisu. D1-tietokantaa ei tarvita.
Yhteys toimii, synkronointi ei
Lue koko virhe. Tarkista R2, käyttörajat, liiterajat ja versiot. Yhteystesti ei takaa myöhemmän kirjoituksen onnistumista. Säilytä virhe ja varmuuskopio; älä tyhjennä sovelluksen tietoja.
Kuva tai asiakirja puuttuu
Sisällytä Ostopaikka ostoliitteitä varten. Avaa alkuperäinen lähettäjällä. Synkronointi ei voi luoda puuttuvaa paikallista tiedostoa. Älä poista koko tietuetta yhden tiedoston vuoksi.
Viralliset ohjeet ja nykyhinnat
Kuvat Cloudflaren hallintapaneelista 25.–26. lokakuuta 2026. Jos ulkoasu muuttuu, seuraa vaiheiden kenttien nimiä ja arvoja.