diff --git a/CLAUDE.md b/CLAUDE.md index 51cc04b..5673ae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,7 +385,9 @@ Cobranza: `ROL_COBRANZA` (correos separados por coma: efrain@, tania@, leo@), `A con "helgen.tech: lector" aunque el código no compartía nada). Por eso todo lo de cobranza (carpeta, libro, subcarpetas de año y proyecto, comprobantes) pasa por `privado_()`, que lo deja en "Restringido" y conserva a las personas agregadas una por una. `prepararCobranza()` repara carpeta y libro si alguien los vuelve a compartir. Si se crean archivos en otra área privada, - hacer lo mismo. + hacer lo mismo. **`DriveApp.setSharing` no sirve aquí** (da "Access denied: DriveApp." aun siendo dueño, con `Permission.NONE` + o `VIEW`): `privado_()` borra los permisos `domain`/`anyone` con la API REST de Drive (`UrlFetchApp` + `ScriptApp.getOAuthToken()`, + sin permisos nuevos). - **Permisos en el servidor:** `ROL_COBRANZA` (Script Properties, no en el código) para ver y cambiar todo lo de cobranza; `ADMIN_DESBLOQUEO` solo para desbloquear. Los demás reciben `codigo: "PERMISO"` y `getDatosIniciales` no les manda nada de cobranza (solo `permisos` en falso para ocultar la pestaña). diff --git a/Code.js b/Code.js index 605bbf9..1e8a94a 100644 --- a/Code.js +++ b/Code.js @@ -1050,12 +1050,23 @@ function exigirCobranza_() { } /** - * Deja un archivo o carpeta de cobranza en "Restringido": quita el acceso por enlace o de todo el dominio y conserva a + * Deja un archivo o carpeta de cobranza en "Restringido": quita el acceso de todo el dominio o por enlace y conserva a * las personas agregadas una por una (tania@, leo@). Workspace comparte con todo helgen.tech lo que se crea (§6.11). + * Usa la API de Drive con el token del script: DriveApp.setSharing da "Access denied: DriveApp." en helgen.tech + * (20/10/2026). No pide permisos nuevos (el de Drive ya lo da DriveApp). Si no puede, lanza error: nada queda abierto. */ function privado_(item) { - // Permission.NONE no está soportado (da "Access denied: DriveApp"); con PRIVATE el permiso se ignora. - if (item.getSharingAccess() !== DriveApp.Access.PRIVATE) item.setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.VIEW); + const url = 'https://www.googleapis.com/drive/v3/files/' + encodeURIComponent(item.getId()) + '/permissions'; + const opciones = { headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() }, muteHttpExceptions: true }; + const r = UrlFetchApp.fetch(url + '?supportsAllDrives=true&fields=permissions(id,type)', opciones); + if (r.getResponseCode() !== 200) throw new Error('No se pudo revisar el acceso en Drive (' + r.getResponseCode() + '): ' + r.getContentText()); + (JSON.parse(r.getContentText()).permissions || []) + .filter(p => p.type === 'domain' || p.type === 'anyone') + .forEach(p => { + const d = UrlFetchApp.fetch(url + '/' + encodeURIComponent(p.id) + '?supportsAllDrives=true', + Object.assign({ method: 'delete' }, opciones)); + if (d.getResponseCode() >= 300) throw new Error('No se pudo restringir el acceso en Drive (' + d.getResponseCode() + '): ' + d.getContentText()); + }); return item; } @@ -1528,8 +1539,8 @@ function prepararCobranza() { const libro = libroCobranza_(); const archivoLibro = privado_(DriveApp.getFileById(libro.getId())); const carpeta = carpetaCobranza_(); - Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (acceso: ' + carpeta.getSharingAccess() + ')'); - Logger.log('Libro de cobranza: ' + libro.getUrl() + ' (acceso: ' + archivoLibro.getSharingAccess() + ')'); + Logger.log('Carpeta de cobranza: ' + carpeta.getUrl() + ' (restringida)'); + Logger.log('Libro de cobranza: ' + archivoLibro.getUrl() + ' (restringido)'); Logger.log('ROL_COBRANZA: ' + (PropertiesService.getScriptProperties().getProperty('ROL_COBRANZA') || '(vacío: nadie tiene acceso)')); } diff --git a/tests/cobranza.test.js b/tests/cobranza.test.js index 49e664e..c9469b3 100644 --- a/tests/cobranza.test.js +++ b/tests/cobranza.test.js @@ -35,7 +35,15 @@ function libro(id,nombre,pestanas){const L={id,nombre,pest:pestanas||{},getId:() // ---- Drive simulado ---- let nId=0; const carpetas={}, archivos={}; // Como Workspace de helgen.tech: todo lo nuevo nace compartido con el dominio (acceso 'DOMAIN') -const compartible=o=>Object.assign(o,{acceso:'DOMAIN',getSharingAccess:()=>o.acceso,setSharing:(a,p)=>{if(p==='NONE')throw new Error('Access denied: DriveApp.');o.acceso=a;o.permiso=p}}); // NONE no está soportado en Apps Script +// DriveApp.setSharing da "Access denied" en helgen.tech: el código usa la API de Drive (permissions) con el token del script +const compartible=o=>Object.assign(o,{acceso:'DOMAIN',setSharing:()=>{throw new Error('Access denied: DriveApp.')}}); +function apiDrive(url,op){const m=url.match(/drive\/v3\/files\/([^/?]+)\/permissions(?:\/([^?]+))?/); + const it=carpetas[decodeURIComponent(m[1])]||archivos[decodeURIComponent(m[1])]; + assert(op&&op.headers&&op.headers.Authorization==='Bearer TOKEN-SCRIPT','API de Drive con el token del script'); + const resp=(c,b)=>({getResponseCode:()=>c,getContentText:()=>JSON.stringify(b||{})}); + if(!it)return resp(404); + if(op.method==='delete'){assert.equal(m[2],'dom');it.acceso='PRIVATE';return resp(204)} + return resp(200,{permissions:[{id:'own',type:'user'}].concat(it.acceso==='DOMAIN'?[{id:'dom',type:'domain'}]:[])})} function carpeta(nombre,padre){const f=compartible({id:'F'+(++nId),nombre,padre,hijos:[],archivos:[],trashed:false}); Object.assign(f,{ getId:()=>f.id,getName:()=>f.nombre,getUrl:()=>'https://drive/'+f.id,isTrashed:()=>f.trashed, getFoldersByName:n=>{const l=f.hijos.filter(h=>h.nombre===n);let i=0;return{hasNext:()=>il[i++]}}, @@ -69,7 +77,8 @@ const ctx={console:{...console,warn(){}},Date,JSON, Utilities:{formatDate:fmt,newBlob:(a,b,c)=>typeof a==='string'?{getAs:()=>({setName:n=>({name:n,html:a})})}:{bytes:a,mime:b,name:c}, base64Encode:b=>Buffer.from(b).toString('base64'),base64Decode:s=>firmados(Buffer.from(s,'base64')), DigestAlgorithm:{SHA_256:'sha256'},computeDigest:(a,s)=>[...require('crypto').createHash('sha256').update(s).digest()]}, - UrlFetchApp:{fetch:url=>{const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; + ScriptApp:{getOAuthToken:()=>'TOKEN-SCRIPT'}, + UrlFetchApp:{fetch:(url,op)=>{if(/googleapis\.com\/drive\//.test(url))return apiDrive(url,op);const t=decodeURIComponent(url.split('id_token=')[1]);const c=tokens[t]; return{getResponseCode:()=>c?200:400,getContentText:()=>JSON.stringify(c||{error:'invalid_token'})}}}, ContentService:{MimeType:{JSON:'json'},createTextOutput:t=>({t,setMimeType(){return this}})}, SpreadsheetApp:{flush(){},openById:id=>{if(!libros[id])throw new Error('No encontrado');return libros[id]}, @@ -78,8 +87,7 @@ const ctx={console:{...console,warn(){}},Date,JSON, DriveApp:{getFolderById:id=>{if(!carpetas[id])throw new Error('No se encontró');return carpetas[id]}, createFolder:n=>miUnidad.createFolder(n),getRootFolder:()=>miUnidad, getFileById:id=>({getParents:()=>{let d=false;return{hasNext:()=>!d,next:()=>{d=true;return archivos[id].padre}}}, - moveTo:f=>{archivos[id].padre=f},getSharingAccess:()=>archivos[id].getSharingAccess(), - setSharing:(a,p)=>archivos[id].setSharing(a,p),getId:()=>id}), + moveTo:f=>{archivos[id].padre=f},setSharing:()=>archivos[id].setSharing(),getId:()=>id,getUrl:()=>'https://drive/file/'+id}), Access:{PRIVATE:'PRIVATE',DOMAIN:'DOMAIN'},Permission:{NONE:'NONE',VIEW:'VIEW'}}, }; vm.createContext(ctx); vm.runInContext(src,ctx);