My Maintenance · 0.6.44 · Windows & Android
Configura tu sincronización con Cloudflare
Guía ilustrada de My Maintenance 0.6.44 para Windows y Android. La nube es opcional; la app y las copias locales funcionan sin ella. Las imágenes muestran Cloudflare en inglés y omiten los datos de la cuenta. Los nombres son ejemplos.
Pasos
- Activa R2 y revisa los precios
- Crea un bucket privado
- Crea el Worker
- Instala el código de My Maintenance
- Conecta el bucket privado
- Crea y guarda la clave privada
- Copia la dirección del Worker
- Conecta el primer dispositivo
- Elige el alcance y sincroniza
- Conecta y comprueba el segundo dispositivo
- Conserva copias independientes
01 / 11
Activa R2 y revisa los precios
Inicia sesión y abre Storage & databases → R2 Object Storage. Si se solicita, activa R2 y revisa personalmente las condiciones de pago. R2 incluye una cuota mensual gratuita, pero puede requerir datos de pago y cobrar el uso adicional. Consulta los precios actuales de R2 y Workers y configura alertas de uso. My Maintenance es gratis.
02 / 11
Crea un bucket privado
En R2 elige Create bucket, por ejemplo my-maintenance-private. Selecciona Standard y la región o jurisdicción adecuada. Pulsa Create bucket. Mantén desactivado el acceso público: no actives r2.dev ni un dominio público. Seleccionarás este mismo bucket en el paso 5.

03 / 11
Crea el Worker
Abre Compute → Workers & Pages → Create application → Start with Hello World! Usa un nombre único, como my-maintenance-sync, y pulsa Deploy. Deja Protect with Cloudflare Access desactivado; la app usa su propia clave. El servicio inicial aún no contiene el código de sincronización.


04 / 11
Instala el código de My Maintenance
Descarga el código de Worker que aparece más abajo. Abre el Worker → Edit code → worker.js. Sustituye todo el código inicial por el contenido completo del archivo descargado. No incluyas la clave en el código. Pulsa Deploy. Unauthorized en la vista previa sin clave es normal.
Mostrar código
// 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
Conecta el bucket privado
Abre Settings → Bindings → Add binding. Elige R2 Bucket → Add Binding. Variable name debe ser exactamente MAINTENANCE. Selecciona el bucket privado del paso 2 y pulsa Deploy. Si el nombre ya existe, edita esa vinculación. La imagen muestra un bucket de prueba; elige el tuyo.


06 / 11
Crea y guarda la clave privada
Genera una clave aleatoria segura de al menos 32 caracteres o usa el generador local. Guárdala en tu gestor de contraseñas. Abre Settings → Runtime variables and secrets → Add variable: Type = Secret, Name = SYNC_TOKEN y Value = tu clave. Pulsa Deploy. Cloudflare no volverá a mostrar el valor. No es un token API de la cuenta Cloudflare.


07 / 11
Copia la dirección del Worker
En Overview copia la dirección HTTPS workers.dev, por ejemplo https://my-maintenance-sync.your-subdomain.workers.dev/. Usa solo la raíz, sin ruta de archivo, consulta ni dirección del panel. Un navegador sin clave debe recibir Unauthorized. No publiques la clave junto a la dirección.
08 / 11
Conecta el primer dispositivo
En My Maintenance abre Ajustes → Sincronización opcional en la nube y elige Cloudflare. Introduce la dirección HTTPS y la clave SYNC_TOKEN. Marca el consentimiento y guarda la conexión; espera la confirmación. Comprueba la conexión. Guardar o comprobar no combina registros; sincronizar también verifica la escritura segura.

09 / 11
Elige el alcance y sincroniza
Elige solo mantenimiento o incluye «Dónde se compró» para transferir ambos módulos, categorías, productos, fotos y documentos. Pulsa Sincronizar ahora y espera. Si hay conflictos, compara ambos lados y elige qué conservar. Las eliminaciones también se sincronizan. Recordatorios de ubicación, permisos, copias y claves siguen siendo propios de cada dispositivo.
10 / 11
Conecta y comprueba el segundo dispositivo
Configura la misma dirección y clave en el otro dispositivo. Elige el mismo alcance y sincroniza. Si ambos tenían cambios, sincroniza de nuevo el primero. Comprueba un activo, un plan, un producto, una foto y un documento en ambos. Solo mantenimiento no modifica las compras. Usa versiones compatibles actualizadas.
11 / 11
Conserva copias independientes
La sincronización no es una copia de seguridad: una eliminación puede llegar a todos los dispositivos. Mantén copias locales y otra copia segura. Desactivar la nube conserva los datos locales. Quitar la conexión elimina la clave del dispositivo, no los archivos de la nube. Si se filtra una clave, cambia SYNC_TOKEN y actualiza todos los dispositivos.
Solución de problemas
Unauthorized / 401
La clave de la app debe coincidir exactamente con SYNC_TOKEN, sin espacios añadidos. El tipo debe ser Secret; publica con Deploy. Unauthorized en un navegador sin clave es normal.
Vinculación ausente o error de servidor
Comprueba MAINTENANCE, el bucket privado, el código completo y el último despliegue. No se necesita D1.
La conexión funciona, pero la sincronización falla
Lee el error completo; comprueba R2, sus cuotas, los límites de adjuntos y las versiones. Comprobar la conexión no garantiza una escritura posterior. Conserva el error y tu copia; no borres los datos de la app.
Falta una foto o un documento
Incluye «Dónde se compró» para adjuntos de compras. Comprueba el original en el emisor. La sincronización no puede recrear archivos locales ausentes. No borres todo el registro por un archivo.
Instrucciones oficiales y precios actuales
Imágenes del panel Cloudflare del 25–26 de octubre de 2026. Si cambia el diseño, sigue los nombres de campos y valores de estos pasos.