kdrive fix
This commit is contained in:
parent
fb3a1f0a51
commit
b405cebdec
24 changed files with 2367 additions and 565 deletions
107
env_dev.20260428_213450.backup
Normal file
107
env_dev.20260428_213450.backup
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Development Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = dev
|
||||
APP_ENV_LABEL = Development Instance Patrick
|
||||
APP_API_URL = http://localhost:8000
|
||||
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=localhost
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
|
||||
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak OAuth -- Data App (kDrive + Mail)
|
||||
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
|
||||
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
|
||||
Service_INFOMANIAK_OAUTH_REDIRECT_URI = http://localhost:8000/api/infomaniak/auth/connect/callback
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
|
||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
|
||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
|
||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
||||
# The bot will connect back to localhost:8000 via WebSocket
|
||||
TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
|
||||
|
||||
# Manadate Pre-Processing Servers
|
||||
PREPROCESS_ALTHAUS_CHAT_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGbEphQ3ZUMlFMQ2EwSGpoSE9NNzRJNTJtaGk1N0RGakdIYnVVeVFHZmF5OXB3QTVWLVNaZk9wNkhfQkZWRnVwRGRxem9iRzJIWXdpX1NIN2FwSExfT3c9PQ==
|
||||
|
||||
# Preprocessor API Configuration
|
||||
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
|
||||
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
||||
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
||||
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
||||
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
||||
|
||||
|
|
@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||
|
|
|
|||
100
env_int.20260428_213451.backup
Normal file
100
env_int.20260428_213451.backup
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Integration Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = int
|
||||
APP_ENV_LABEL = Integration Instance
|
||||
APP_API_URL = https://gateway-int.poweron-center.net
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-int-server.postgres.database.azure.com
|
||||
DB_USER=heeshkdlby
|
||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak OAuth -- Data App (kDrive + Mail)
|
||||
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
|
||||
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
|
||||
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/infomaniak/auth/connect/callback
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
|
||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
|
||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
|
||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
||||
|
||||
# Manadate Pre-Processing Servers
|
||||
PREPROCESS_ALTHAUS_CHAT_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4UkNBelhvckxCQUVjZm94N3BZUDcxaEMyckE2dm1lRVhqODhrWU1SUjNXZ3dQZlVJOWhveXFkZXpobW5xT0NneGZ2SkNUblFmYXd0WTBYNTl3UmRnSWc9PQ==
|
||||
|
||||
# Preprocessor API Configuration
|
||||
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
|
||||
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
|
@ -49,11 +49,13 @@ Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/go
|
|||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
||||
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
|
||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
|
|
|||
101
env_prod.20260428_213451.backup
Normal file
101
env_prod.20260428_213451.backup
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance
|
||||
APP_KEY_SYSVAR = CONFIG_KEY
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://gateway-prod.poweron-center.net
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||
DB_USER=gzxxmcrdhn
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak OAuth -- Data App (kDrive + Mail)
|
||||
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
|
||||
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
|
||||
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/infomaniak/auth/connect/callback
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
||||
|
||||
# Manadate Pre-Processing Servers
|
||||
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
|
||||
|
||||
# Preprocessor API Configuration
|
||||
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
|
||||
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
|
@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
|
|
|
|||
101
env_prod_forgejo.20260428_213451.backup
Normal file
101
env_prod_forgejo.20260428_213451.backup
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Production Environment Configuration
|
||||
|
||||
# System Configuration
|
||||
APP_ENV_TYPE = prod
|
||||
APP_ENV_LABEL = Production Instance Forgejo
|
||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
||||
APP_API_URL = https://api.poweron.swiss
|
||||
|
||||
# PostgreSQL DB Host
|
||||
DB_HOST=10.20.0.21
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = mypassword
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
||||
APP_TOKEN_EXPIRY=300
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
APP_LOGGING_LOG_DIR = srv/gateway/shared/logs
|
||||
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
|
||||
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
APP_LOGGING_FILE_ENABLED = True
|
||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
|
||||
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
||||
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
|
||||
|
||||
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||
Service_GOOGLE_AUTH_REDIRECT_URI =
|
||||
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||
Service_GOOGLE_DATA_REDIRECT_URI =
|
||||
|
||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak OAuth -- Data App (kDrive + Mail)
|
||||
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
|
||||
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
|
||||
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/infomaniak/auth/connect/callback
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||
|
||||
# Feature SyncDelta JIRA configuration
|
||||
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
|
||||
|
||||
# Teamsbot Browser Bot Service
|
||||
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
|
||||
|
||||
# Debug Configuration
|
||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
||||
|
||||
# Manadate Pre-Processing Servers
|
||||
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
|
||||
|
||||
# Preprocessor API Configuration
|
||||
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
|
||||
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
|
||||
|
||||
# Azure Communication Services Email Configuration
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||
|
|
@ -11,7 +11,7 @@ APP_API_URL = https://api.poweron.swiss
|
|||
# PostgreSQL DB Host
|
||||
DB_HOST=10.20.0.21
|
||||
DB_USER=poweron_dev
|
||||
DB_PASSWORD_SECRET = mypassword
|
||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
||||
DB_PORT=5432
|
||||
|
||||
# Security Configuration
|
||||
|
|
@ -51,6 +51,8 @@ Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
|
||||
|
||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
||||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
|
|
|
|||
|
|
@ -11,6 +11,30 @@ from modules.datamodels.datamodelAi import AiModel, PriorityEnum, ProcessingMode
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _supportsCustomTemperature(modelName: str) -> bool:
|
||||
"""Check whether an OpenAI model accepts a custom `temperature` value.
|
||||
|
||||
GPT-5.x and the o-series (o1/o3/o4) reasoning models reject every
|
||||
`temperature` value other than the default (1) with HTTP 400
|
||||
`unsupported_value`. For these models we must omit `temperature`
|
||||
from the payload entirely. Older chat-completions models
|
||||
(gpt-4o, gpt-4o-mini, gpt-4.1, gpt-3.5-*) still accept any value
|
||||
in [0, 2].
|
||||
|
||||
Returns:
|
||||
True if `temperature` may be sent; False if it must be omitted.
|
||||
"""
|
||||
if not modelName:
|
||||
return True
|
||||
name = modelName.lower()
|
||||
if name.startswith("gpt-5"):
|
||||
return False
|
||||
if name.startswith("o1") or name.startswith("o3") or name.startswith("o4"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def loadConfigData():
|
||||
"""Load configuration data for OpenAI connector"""
|
||||
return {
|
||||
|
|
@ -344,13 +368,17 @@ class AiOpenai(BaseConnectorAi):
|
|||
payload = {
|
||||
"model": model.name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
# Universal output-length cap. `max_tokens` is deprecated and
|
||||
# rejected outright by gpt-5.x / o-series; `max_completion_tokens`
|
||||
# is accepted by every current chat-completions model (legacy
|
||||
# gpt-4o, gpt-4.1, gpt-5.x, o1/o3/o4) per OpenAI API reference.
|
||||
"max_completion_tokens": maxTokens
|
||||
}
|
||||
# gpt-5.x and o-series only accept the default temperature (1) and
|
||||
# return HTTP 400 `unsupported_value` for anything else - omit the
|
||||
# field entirely for those models.
|
||||
if _supportsCustomTemperature(model.name):
|
||||
payload["temperature"] = temperature
|
||||
|
||||
if modelCall.tools:
|
||||
payload["tools"] = modelCall.tools
|
||||
|
|
@ -428,13 +456,15 @@ class AiOpenai(BaseConnectorAi):
|
|||
payload: Dict[str, Any] = {
|
||||
"model": model.name,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
# See callAiBasic for the rationale: `max_completion_tokens`
|
||||
# is the universal output-length parameter; `max_tokens` is
|
||||
# deprecated and rejected by gpt-5.x / o-series.
|
||||
"max_completion_tokens": model.maxTokens,
|
||||
"stream": True,
|
||||
}
|
||||
if _supportsCustomTemperature(model.name):
|
||||
payload["temperature"] = temperature
|
||||
|
||||
if modelCall.tools:
|
||||
payload["tools"] = modelCall.tools
|
||||
payload["tool_choice"] = modelCall.toolChoice or "auto"
|
||||
|
|
@ -585,15 +615,15 @@ class AiOpenai(BaseConnectorAi):
|
|||
# Use the messages directly - they should already contain the image data
|
||||
# in the format: {"type": "image_url", "image_url": {"url": "data:...base64,..."}}
|
||||
|
||||
# Use parameters from model
|
||||
temperature = model.temperature
|
||||
# Don't set maxTokens - let the model use its full context length
|
||||
|
||||
payload = {
|
||||
"model": model.name,
|
||||
"messages": messages,
|
||||
"temperature": temperature
|
||||
}
|
||||
if _supportsCustomTemperature(model.name):
|
||||
payload["temperature"] = temperature
|
||||
|
||||
response = await self.httpClient.post(
|
||||
model.apiUrl,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ googleAuthScopes = [
|
|||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
|
||||
# Google — Data app (Gmail + Drive + identity for token responses)
|
||||
# Google — Data app (Gmail + Drive + Calendar + Contacts + identity for token responses)
|
||||
googleDataScopes = [
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
]
|
||||
|
||||
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
|
||||
|
|
@ -34,6 +36,8 @@ msftDataScopes = [
|
|||
"OnlineMeetings.Read",
|
||||
"Chat.ReadWrite",
|
||||
"ChatMessage.Send",
|
||||
"Calendars.Read",
|
||||
"Contacts.Read",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -42,14 +46,8 @@ def msftDataScopesForRefresh() -> str:
|
|||
return " ".join(msftDataScopes)
|
||||
|
||||
|
||||
# Infomaniak — Data app (kDrive + Mail; user_info needed for /1/profile lookup)
|
||||
infomaniakDataScopes = [
|
||||
"user_info",
|
||||
"kdrive",
|
||||
"mail",
|
||||
]
|
||||
|
||||
|
||||
def infomaniakDataScopesForRefresh() -> str:
|
||||
"""Space-separated scope string identical to authorization request."""
|
||||
return " ".join(infomaniakDataScopes)
|
||||
# Infomaniak intentionally has no OAuth scope set: the kDrive + Mail data APIs
|
||||
# are only reachable with manually issued Personal Access Tokens (see
|
||||
# wiki/d-guides/infomaniak-token-setup.md). The OAuth /authorize endpoint at
|
||||
# login.infomaniak.com only accepts identity scopes (openid/profile/email/phone)
|
||||
# and does not return tokens that work against /1/* data routes.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
|||
from modules.datamodels.datamodelUam import AuthAuthority
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
|
||||
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh, infomaniakDataScopesForRefresh
|
||||
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -30,9 +30,6 @@ class TokenManager:
|
|||
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
|
||||
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
|
||||
|
||||
# Infomaniak Data OAuth (kDrive + Mail)
|
||||
self.infomaniak_client_id = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_ID")
|
||||
self.infomaniak_client_secret = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_SECRET")
|
||||
|
||||
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
|
||||
"""Refresh Microsoft OAuth token using refresh token"""
|
||||
|
|
@ -166,65 +163,6 @@ class TokenManager:
|
|||
logger.error(f"Error refreshing Google token: {str(e)}")
|
||||
return None
|
||||
|
||||
def refreshInfomaniakToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
|
||||
"""Refresh Infomaniak OAuth token using refresh token"""
|
||||
try:
|
||||
logger.debug(f"refreshInfomaniakToken: Starting Infomaniak token refresh for user {userId}")
|
||||
|
||||
if not self.infomaniak_client_id or not self.infomaniak_client_secret:
|
||||
logger.error("Infomaniak OAuth configuration not found")
|
||||
return None
|
||||
|
||||
tokenUrl = "https://login.infomaniak.com/token"
|
||||
data = {
|
||||
"client_id": self.infomaniak_client_id,
|
||||
"client_secret": self.infomaniak_client_secret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"scope": infomaniakDataScopesForRefresh(),
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.post(tokenUrl, data=data)
|
||||
logger.debug(f"refreshInfomaniakToken: HTTP response status: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
tokenData = response.json()
|
||||
if "access_token" not in tokenData:
|
||||
logger.error("Infomaniak token refresh response missing access_token")
|
||||
return None
|
||||
|
||||
newToken = Token(
|
||||
userId=userId,
|
||||
authority=AuthAuthority.INFOMANIAK,
|
||||
connectionId=oldToken.connectionId,
|
||||
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||
tokenAccess=tokenData["access_token"],
|
||||
tokenRefresh=tokenData.get("refresh_token", refreshToken),
|
||||
tokenType=tokenData.get("token_type", "bearer"),
|
||||
expiresAt=createExpirationTimestamp(tokenData.get("expires_in", 3600)),
|
||||
createdAt=getUtcTimestamp(),
|
||||
)
|
||||
return newToken
|
||||
|
||||
logger.error(
|
||||
f"Failed to refresh Infomaniak token: {response.status_code} - {response.text}"
|
||||
)
|
||||
if response.status_code == 400:
|
||||
try:
|
||||
errorData = response.json()
|
||||
if errorData.get("error") == "invalid_grant":
|
||||
logger.warning(
|
||||
"Infomaniak refresh token is invalid or expired - user needs to re-authenticate"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing Infomaniak token: {str(e)}")
|
||||
return None
|
||||
|
||||
def refreshToken(self, oldToken: Token) -> Optional[Token]:
|
||||
"""Refresh an expired token using the appropriate OAuth service"""
|
||||
try:
|
||||
|
|
@ -268,9 +206,6 @@ class TokenManager:
|
|||
elif oldToken.authority == AuthAuthority.GOOGLE:
|
||||
logger.debug(f"refreshToken: Refreshing Google token")
|
||||
return self.refreshGoogleToken(oldToken.tokenRefresh, oldToken.userId, oldToken)
|
||||
elif oldToken.authority == AuthAuthority.INFOMANIAK:
|
||||
logger.debug(f"refreshToken: Refreshing Infomaniak token")
|
||||
return self.refreshInfomaniakToken(oldToken.tokenRefresh, oldToken.userId, oldToken)
|
||||
else:
|
||||
logger.warning(f"Unknown authority for token refresh: {oldToken.authority}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -144,45 +144,6 @@ class TokenRefreshService:
|
|||
logger.error(f"Error refreshing Microsoft token for connection {connection.id}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def _refresh_infomaniak_token(self, interface, connection: UserConnection) -> bool:
|
||||
"""Refresh Infomaniak OAuth token"""
|
||||
try:
|
||||
logger.debug(f"Refreshing Infomaniak token for connection {connection.id}")
|
||||
|
||||
current_token = interface.getConnectionToken(connection.id)
|
||||
if not current_token:
|
||||
logger.warning(f"No Infomaniak token found for connection {connection.id}")
|
||||
return False
|
||||
|
||||
from modules.auth.tokenManager import TokenManager
|
||||
token_manager = TokenManager()
|
||||
|
||||
refreshedToken = token_manager.refreshToken(current_token)
|
||||
if refreshedToken:
|
||||
interface.saveConnectionToken(refreshedToken)
|
||||
interface.db.recordModify(UserConnection, connection.id, {
|
||||
"lastChecked": getUtcTimestamp(),
|
||||
"expiresAt": refreshedToken.expiresAt,
|
||||
})
|
||||
logger.info(f"Successfully refreshed Infomaniak token for connection {connection.id}")
|
||||
try:
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(connection.userId),
|
||||
mandateId="system",
|
||||
action="token_refresh",
|
||||
details=f"Infomaniak token refreshed for connection {connection.id}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
logger.warning(f"Failed to refresh Infomaniak token for connection {connection.id}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing Infomaniak token for connection {connection.id}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def refresh_expired_tokens(self, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh expired OAuth tokens for a user
|
||||
|
|
@ -216,7 +177,7 @@ class TokenRefreshService:
|
|||
for connection in connections:
|
||||
# Only refresh expired OAuth connections
|
||||
if (connection.tokenStatus == 'expired' and
|
||||
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT, AuthAuthority.INFOMANIAK]):
|
||||
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT]):
|
||||
|
||||
# Check rate limiting
|
||||
if self._is_rate_limited(connection.id):
|
||||
|
|
@ -233,8 +194,6 @@ class TokenRefreshService:
|
|||
success = await self._refresh_google_token(root_interface, connection)
|
||||
elif connection.authority == AuthAuthority.MSFT:
|
||||
success = await self._refresh_microsoft_token(root_interface, connection)
|
||||
elif connection.authority == AuthAuthority.INFOMANIAK:
|
||||
success = await self._refresh_infomaniak_token(root_interface, connection)
|
||||
|
||||
if success:
|
||||
refreshed_count += 1
|
||||
|
|
@ -289,7 +248,7 @@ class TokenRefreshService:
|
|||
# Only refresh active tokens that expire soon
|
||||
if (connection.tokenStatus == 'active' and
|
||||
connection.tokenExpiresAt and
|
||||
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT, AuthAuthority.INFOMANIAK]):
|
||||
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT]):
|
||||
|
||||
# Check if token expires within 5 minutes
|
||||
time_until_expiry = connection.tokenExpiresAt - current_time
|
||||
|
|
@ -310,8 +269,6 @@ class TokenRefreshService:
|
|||
success = await self._refresh_google_token(root_interface, connection)
|
||||
elif connection.authority == AuthAuthority.MSFT:
|
||||
success = await self._refresh_microsoft_token(root_interface, connection)
|
||||
elif connection.authority == AuthAuthority.INFOMANIAK:
|
||||
success = await self._refresh_infomaniak_token(root_interface, connection)
|
||||
|
||||
if success:
|
||||
refreshed_count += 1
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
_DRIVE_BASE = "https://www.googleapis.com/drive/v3"
|
||||
_GMAIL_BASE = "https://gmail.googleapis.com/gmail/v1"
|
||||
_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"
|
||||
_PEOPLE_BASE = "https://people.googleapis.com/v1"
|
||||
|
||||
|
||||
async def _googleGet(token: str, url: str) -> Dict[str, Any]:
|
||||
|
|
@ -274,12 +276,480 @@ class GmailAdapter(ServiceAdapter):
|
|||
]
|
||||
|
||||
|
||||
class CalendarAdapter(ServiceAdapter):
|
||||
"""Google Calendar ServiceAdapter -- browse calendars, list events, .ics download.
|
||||
|
||||
Path conventions:
|
||||
``""`` / ``"/"`` -> list calendars from ``calendarList``
|
||||
``"/<calendarId>"`` -> list upcoming events in that calendar
|
||||
``"/<calendarId>/<eventId>"`` -> reserved for future event detail browse
|
||||
"""
|
||||
|
||||
_DEFAULT_EVENT_LIMIT = 100
|
||||
_MAX_EVENT_LIMIT = 2500
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self._token = accessToken
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
path: str,
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
logger.warning(f"Google Calendar list failed: {result['error']}")
|
||||
return []
|
||||
calendars = result.get("items", [])
|
||||
if filter:
|
||||
f = filter.lower()
|
||||
calendars = [c for c in calendars if f in (c.get("summary") or "").lower()]
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=c.get("summaryOverride") or c.get("summary", ""),
|
||||
path=f"/{c.get('id', '')}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"id": c.get("id"),
|
||||
"primary": c.get("primary", False),
|
||||
"accessRole": c.get("accessRole"),
|
||||
"backgroundColor": c.get("backgroundColor"),
|
||||
"timeZone": c.get("timeZone"),
|
||||
},
|
||||
)
|
||||
for c in calendars
|
||||
]
|
||||
|
||||
from urllib.parse import quote
|
||||
calendarId = cleanPath.split("/", 1)[0]
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
url = (
|
||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?maxResults={effectiveLimit}&orderBy=startTime&singleEvents=true"
|
||||
)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
logger.warning(f"Google Calendar events failed: {result['error']}")
|
||||
return []
|
||||
events = result.get("items", [])
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("summary", "(no title)"),
|
||||
path=f"/{calendarId}/{ev.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/calendar",
|
||||
metadata={
|
||||
"id": ev.get("id"),
|
||||
"start": (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date"),
|
||||
"end": (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date"),
|
||||
"location": ev.get("location"),
|
||||
"organizer": (ev.get("organizer") or {}).get("email"),
|
||||
"htmlLink": ev.get("htmlLink"),
|
||||
"status": ev.get("status"),
|
||||
},
|
||||
)
|
||||
for ev in events
|
||||
]
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
from urllib.parse import quote
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
return DownloadResult()
|
||||
calendarId, eventId = cleanPath.split("/", 1)
|
||||
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
|
||||
ev = await _googleGet(self._token, url)
|
||||
if "error" in ev:
|
||||
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
|
||||
return DownloadResult()
|
||||
icsBytes = _googleEventToIcs(ev)
|
||||
summary = ev.get("summary") or eventId
|
||||
safeName = _googleSafeFileName(summary) or "event"
|
||||
return DownloadResult(
|
||||
data=icsBytes,
|
||||
fileName=f"{safeName}.ics",
|
||||
mimeType="text/calendar",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Google Calendar upload not supported"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
from urllib.parse import quote
|
||||
calendarId = (path or "").strip("/").split("/", 1)[0] or "primary"
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
url = (
|
||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
|
||||
)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("summary", "(no title)"),
|
||||
path=f"/{calendarId}/{ev.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/calendar",
|
||||
metadata={
|
||||
"id": ev.get("id"),
|
||||
"start": (ev.get("start") or {}).get("dateTime") or (ev.get("start") or {}).get("date"),
|
||||
"end": (ev.get("end") or {}).get("dateTime") or (ev.get("end") or {}).get("date"),
|
||||
},
|
||||
)
|
||||
for ev in result.get("items", [])
|
||||
]
|
||||
|
||||
|
||||
class ContactsAdapter(ServiceAdapter):
|
||||
"""Google Contacts ServiceAdapter -- People API (read-only).
|
||||
|
||||
Path conventions:
|
||||
``""`` / ``"/"`` -> list contact groups (incl. virtual ``all`` for the user's connections)
|
||||
``"/all"`` -> list all ``people/me/connections``
|
||||
``"/<groupResourceName>"`` -> list members of that contact group (e.g. ``contactGroups/myFriends``)
|
||||
``"/<group>/<personId>"`` -> reserved for future detail browse;
|
||||
``personId`` is the suffix after ``people/``
|
||||
"""
|
||||
|
||||
_DEFAULT_CONTACT_LIMIT = 200
|
||||
_MAX_CONTACT_LIMIT = 1000
|
||||
_PERSON_FIELDS = (
|
||||
"names,emailAddresses,phoneNumbers,organizations,addresses,biographies,memberships"
|
||||
)
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self._token = accessToken
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
path: str,
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
entries: List[ExternalEntry] = [
|
||||
ExternalEntry(
|
||||
name="Alle Kontakte",
|
||||
path="/all",
|
||||
isFolder=True,
|
||||
metadata={"id": "all", "isVirtual": True},
|
||||
),
|
||||
]
|
||||
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" not in result:
|
||||
for grp in result.get("contactGroups", []):
|
||||
name = grp.get("formattedName") or grp.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
entries.append(
|
||||
ExternalEntry(
|
||||
name=name,
|
||||
path=f"/{grp.get('resourceName', '')}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"id": grp.get("resourceName"),
|
||||
"memberCount": grp.get("memberCount", 0),
|
||||
"groupType": grp.get("groupType"),
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Google contactGroups list failed: {result['error']}")
|
||||
return entries
|
||||
|
||||
from urllib.parse import quote
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
groupRef = cleanPath.split("/", 1)[0]
|
||||
if groupRef == "all":
|
||||
url = (
|
||||
f"{_PEOPLE_BASE}/people/me/connections"
|
||||
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
|
||||
)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
logger.warning(f"Google People connections failed: {result['error']}")
|
||||
return []
|
||||
people = result.get("connections", [])
|
||||
else:
|
||||
groupResource = groupRef
|
||||
grpUrl = (
|
||||
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
|
||||
f"?maxMembers={min(effectiveLimit, 1000)}"
|
||||
)
|
||||
grpResult = await _googleGet(self._token, grpUrl)
|
||||
if "error" in grpResult:
|
||||
logger.warning(f"Google contactGroup detail failed: {grpResult['error']}")
|
||||
return []
|
||||
memberResourceNames = grpResult.get("memberResourceNames") or []
|
||||
if not memberResourceNames:
|
||||
return []
|
||||
chunkSize = 200
|
||||
people: List[Dict[str, Any]] = []
|
||||
for i in range(0, min(len(memberResourceNames), effectiveLimit), chunkSize):
|
||||
chunk = memberResourceNames[i : i + chunkSize]
|
||||
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
|
||||
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
|
||||
batchResult = await _googleGet(self._token, batchUrl)
|
||||
if "error" in batchResult:
|
||||
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
|
||||
continue
|
||||
for resp in batchResult.get("responses", []):
|
||||
person = resp.get("person")
|
||||
if person:
|
||||
people.append(person)
|
||||
if len(people) >= effectiveLimit:
|
||||
break
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=_googlePersonLabel(p) or "(no name)",
|
||||
path=f"/{groupRef}/{(p.get('resourceName', '') or '').split('/')[-1]}",
|
||||
isFolder=False,
|
||||
mimeType="text/vcard",
|
||||
metadata={
|
||||
"id": p.get("resourceName"),
|
||||
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
|
||||
"phones": [pn.get("value") for pn in (p.get("phoneNumbers") or []) if pn.get("value")],
|
||||
"organization": (p.get("organizations") or [{}])[0].get("name") if p.get("organizations") else None,
|
||||
},
|
||||
)
|
||||
for p in people[:effectiveLimit]
|
||||
]
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
from urllib.parse import quote
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
return DownloadResult()
|
||||
personSuffix = cleanPath.split("/")[-1]
|
||||
if not personSuffix:
|
||||
return DownloadResult()
|
||||
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
|
||||
person = await _googleGet(self._token, url)
|
||||
if "error" in person:
|
||||
logger.warning(f"Google People fetch failed: {person['error']}")
|
||||
return DownloadResult()
|
||||
vcfBytes = _googlePersonToVcard(person)
|
||||
label = _googlePersonLabel(person) or personSuffix
|
||||
safeName = _googleSafeFileName(label) or "contact"
|
||||
return DownloadResult(
|
||||
data=vcfBytes,
|
||||
fileName=f"{safeName}.vcf",
|
||||
mimeType="text/vcard",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Google Contacts upload not supported"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
from urllib.parse import quote
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
url = (
|
||||
f"{_PEOPLE_BASE}/people:searchContacts"
|
||||
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
|
||||
f"&readMask={self._PERSON_FIELDS}"
|
||||
)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
return []
|
||||
entries: List[ExternalEntry] = []
|
||||
for r in result.get("results", []):
|
||||
p = r.get("person") or {}
|
||||
entries.append(
|
||||
ExternalEntry(
|
||||
name=_googlePersonLabel(p) or "(no name)",
|
||||
path=f"/search/{(p.get('resourceName', '') or '').split('/')[-1]}",
|
||||
isFolder=False,
|
||||
mimeType="text/vcard",
|
||||
metadata={
|
||||
"id": p.get("resourceName"),
|
||||
"emails": [e.get("value") for e in (p.get("emailAddresses") or []) if e.get("value")],
|
||||
},
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _googleSafeFileName(name: str) -> str:
|
||||
import re
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||
|
||||
|
||||
def _googleIcsEscape(value: str) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
|
||||
def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
||||
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
if "T" not in value:
|
||||
dt = datetime.strptime(value, "%Y-%m-%d")
|
||||
return dt.strftime("%Y%m%d")
|
||||
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
|
||||
from datetime import datetime, timezone
|
||||
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
|
||||
summary = _googleIcsEscape(event.get("summary") or "")
|
||||
location = _googleIcsEscape(event.get("location") or "")
|
||||
description = _googleIcsEscape(event.get("description") or "")
|
||||
rawStart = (event.get("start") or {}).get("dateTime") or (event.get("start") or {}).get("date")
|
||||
rawEnd = (event.get("end") or {}).get("dateTime") or (event.get("end") or {}).get("date")
|
||||
isAllDay = bool((event.get("start") or {}).get("date") and not (event.get("start") or {}).get("dateTime"))
|
||||
dtstart = _googleIcsDateTime(rawStart)
|
||||
dtend = _googleIcsDateTime(rawEnd)
|
||||
dtstamp = _googleIcsDateTime(event.get("updated")) or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//PowerOn//Google-Calendar-Adapter//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{dtstamp}",
|
||||
]
|
||||
if dtstart:
|
||||
lines.append(f"DTSTART;VALUE=DATE:{dtstart}" if isAllDay else f"DTSTART:{dtstart}")
|
||||
if dtend:
|
||||
lines.append(f"DTEND;VALUE=DATE:{dtend}" if isAllDay else f"DTEND:{dtend}")
|
||||
if summary:
|
||||
lines.append(f"SUMMARY:{summary}")
|
||||
if location:
|
||||
lines.append(f"LOCATION:{location}")
|
||||
if description:
|
||||
lines.append(f"DESCRIPTION:{description}")
|
||||
organizer = (event.get("organizer") or {}).get("email")
|
||||
if organizer:
|
||||
lines.append(f"ORGANIZER:mailto:{organizer}")
|
||||
for att in (event.get("attendees") or []):
|
||||
addr = att.get("email")
|
||||
if addr:
|
||||
lines.append(f"ATTENDEE:mailto:{addr}")
|
||||
lines.append("END:VEVENT")
|
||||
lines.append("END:VCALENDAR")
|
||||
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
||||
|
||||
|
||||
def _googlePersonLabel(person: Dict[str, Any]) -> str:
|
||||
names = person.get("names") or []
|
||||
if names:
|
||||
primary = names[0]
|
||||
display = primary.get("displayName") or ""
|
||||
if display:
|
||||
return display
|
||||
given = primary.get("givenName") or ""
|
||||
family = primary.get("familyName") or ""
|
||||
full = f"{given} {family}".strip()
|
||||
if full:
|
||||
return full
|
||||
orgs = person.get("organizations") or []
|
||||
if orgs and orgs[0].get("name"):
|
||||
return orgs[0]["name"]
|
||||
emails = person.get("emailAddresses") or []
|
||||
if emails and emails[0].get("value"):
|
||||
return emails[0]["value"]
|
||||
return ""
|
||||
|
||||
|
||||
def _googlePersonToVcard(person: Dict[str, Any]) -> bytes:
|
||||
"""Build a vCard 3.0 from a Google People API person payload."""
|
||||
names = person.get("names") or []
|
||||
primaryName = names[0] if names else {}
|
||||
given = primaryName.get("givenName") or ""
|
||||
family = primaryName.get("familyName") or ""
|
||||
middle = primaryName.get("middleName") or ""
|
||||
fn = primaryName.get("displayName") or _googlePersonLabel(person) or ""
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
f"N:{family};{given};{middle};;",
|
||||
f"FN:{fn}",
|
||||
]
|
||||
orgs = person.get("organizations") or []
|
||||
if orgs:
|
||||
org = orgs[0]
|
||||
orgVal = org.get("name") or ""
|
||||
if org.get("department"):
|
||||
orgVal = f"{orgVal};{org['department']}"
|
||||
if orgVal:
|
||||
lines.append(f"ORG:{orgVal}")
|
||||
if org.get("title"):
|
||||
lines.append(f"TITLE:{org['title']}")
|
||||
for em in (person.get("emailAddresses") or []):
|
||||
addr = em.get("value")
|
||||
if not addr:
|
||||
continue
|
||||
emailType = (em.get("type") or "INTERNET").upper()
|
||||
lines.append(f"EMAIL;TYPE={emailType}:{addr}")
|
||||
for ph in (person.get("phoneNumbers") or []):
|
||||
val = ph.get("value")
|
||||
if not val:
|
||||
continue
|
||||
phType = (ph.get("type") or "VOICE").upper()
|
||||
lines.append(f"TEL;TYPE={phType}:{val}")
|
||||
for addr in (person.get("addresses") or []):
|
||||
street = addr.get("streetAddress") or ""
|
||||
city = addr.get("city") or ""
|
||||
region = addr.get("region") or ""
|
||||
postal = addr.get("postalCode") or ""
|
||||
country = addr.get("country") or ""
|
||||
if any([street, city, region, postal, country]):
|
||||
adrType = (addr.get("type") or "OTHER").upper()
|
||||
lines.append(f"ADR;TYPE={adrType}:;;{street};{city};{region};{postal};{country}")
|
||||
bios = person.get("biographies") or []
|
||||
if bios and bios[0].get("value"):
|
||||
lines.append(f"NOTE:{_googleIcsEscape(bios[0]['value'])}")
|
||||
lines.append(f"UID:{person.get('resourceName', '')}")
|
||||
lines.append("END:VCARD")
|
||||
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
||||
|
||||
|
||||
class GoogleConnector(ProviderConnector):
|
||||
"""Google ProviderConnector -- 1 connection -> Drive + Gmail."""
|
||||
"""Google ProviderConnector -- 1 connection -> Drive + Gmail + Calendar + Contacts."""
|
||||
|
||||
_SERVICE_MAP = {
|
||||
"drive": DriveAdapter,
|
||||
"gmail": GmailAdapter,
|
||||
"calendar": CalendarAdapter,
|
||||
"contact": ContactsAdapter,
|
||||
}
|
||||
|
||||
def getAvailableServices(self) -> List[str]:
|
||||
|
|
|
|||
|
|
@ -1,24 +1,41 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak ProviderConnector -- kDrive and Mail via Infomaniak OAuth.
|
||||
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
|
||||
|
||||
All ServiceAdapters share the same OAuth access token obtained from the
|
||||
UserConnection (authority=infomaniak).
|
||||
The PAT carries one or more of these scopes:
|
||||
|
||||
Path conventions (leading slash):
|
||||
kDrive:
|
||||
/ -- list drives the user has access to
|
||||
/{driveId} -- root folder of a drive (children)
|
||||
- ``drive`` -> kDrive (active here)
|
||||
- ``workspace:calendar`` -> Calendar (active here)
|
||||
- ``workspace:contact`` -> Contacts (active here)
|
||||
- ``workspace:mail`` -> Mail (no public PAT-friendly endpoint yet)
|
||||
|
||||
Mail is intentionally NOT in ``_SERVICE_MAP`` until we find a
|
||||
PAT-authenticated endpoint -- the public ``/1/mail`` and
|
||||
``mail.infomaniak.com/api/pim/mail*`` routes either don't exist (404
|
||||
nginx) or 302 to OAuth, so wiring a stub adapter would only confuse
|
||||
users.
|
||||
|
||||
Path conventions (leading slash, ``ServiceAdapter`` paths always start with
|
||||
``/``):
|
||||
kDrive (api.infomaniak.com, requires ``account_id`` query arg):
|
||||
/ -- list drives in the user's account
|
||||
/{driveId} -- root folder of a drive
|
||||
/{driveId}/{fileId} -- folder children OR file (download)
|
||||
Mail:
|
||||
/ -- list user's mailboxes
|
||||
/{mailboxId} -- folders in mailbox
|
||||
/{mailboxId}/{folderId} -- messages in folder
|
||||
/{mailboxId}/{folderId}/{uid} -- single message (download as .eml)
|
||||
Calendar (calendar.infomaniak.com PIM):
|
||||
/ -- list calendars accessible to the user
|
||||
/{calendarId} -- events of one calendar
|
||||
/{calendarId}/{eventId} -- single event (.ics download)
|
||||
Contacts (contacts.infomaniak.com PIM):
|
||||
/ -- list address books
|
||||
/{addressBookId} -- contacts in that address book
|
||||
/{addressBookId}/{contactId} -- single contact (.vcf download)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
|
@ -32,39 +49,74 @@ from modules.datamodels.datamodelDataSource import ExternalEntry
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.infomaniak.com"
|
||||
_CALENDAR_BASE = "https://calendar.infomaniak.com"
|
||||
_CONTACTS_BASE = "https://contacts.infomaniak.com"
|
||||
_PIM_PREFIX = "/api/pim"
|
||||
|
||||
|
||||
async def _infomaniakGet(token: str, endpoint: str) -> Dict[str, Any]:
|
||||
"""Single GET call against the Infomaniak API. Returns parsed JSON or {'error': ...}."""
|
||||
url = f"{_API_BASE}/{endpoint.lstrip('/')}"
|
||||
class InfomaniakOwnerIdentity(TypedDict):
|
||||
"""Minimal identity payload for the PAT owner.
|
||||
|
||||
``accountId`` is the only field the kDrive adapter needs at runtime.
|
||||
``displayName`` is harvested for the connection UI; both fields come
|
||||
from the same PIM Owner record.
|
||||
"""
|
||||
|
||||
accountId: int
|
||||
displayName: Optional[str]
|
||||
|
||||
|
||||
class InfomaniakIdentityError(RuntimeError):
|
||||
"""Raised when no owner identity can be derived from a PAT."""
|
||||
|
||||
|
||||
async def _infomaniakGet(
|
||||
token: str,
|
||||
endpoint: str,
|
||||
baseUrl: str = _API_BASE,
|
||||
) -> Dict[str, Any]:
|
||||
"""Single GET against an Infomaniak host.
|
||||
|
||||
``endpoint`` is appended to ``baseUrl`` (handles leading slash). Returns
|
||||
parsed JSON, or ``{'error': ...}`` for non-2xx / network failures.
|
||||
"""
|
||||
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
timeout = aiohttp.ClientTimeout(total=20)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
async with session.get(url, headers=headers, allow_redirects=False) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return await resp.json()
|
||||
errorText = await resp.text()
|
||||
logger.warning(f"Infomaniak API {resp.status}: {errorText[:300]}")
|
||||
logger.warning(f"Infomaniak GET {url} -> {resp.status}: {errorText[:300]}")
|
||||
return {"error": f"{resp.status}: {errorText[:200]}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Infomaniak GET {url} crashed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def _infomaniakDownload(token: str, endpoint: str) -> Optional[bytes]:
|
||||
"""Binary download from the Infomaniak API. Returns bytes or None on error."""
|
||||
url = f"{_API_BASE}/{endpoint.lstrip('/')}"
|
||||
async def _infomaniakDownload(
|
||||
token: str,
|
||||
endpoint: str,
|
||||
baseUrl: str = _API_BASE,
|
||||
) -> Optional[bytes]:
|
||||
"""Binary download from an Infomaniak host. Returns bytes or ``None``."""
|
||||
url = f"{baseUrl.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
async with session.get(url, headers=headers, allow_redirects=False) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.read()
|
||||
logger.warning(f"Infomaniak download {resp.status}: {(await resp.text())[:300]}")
|
||||
logger.warning(
|
||||
f"Infomaniak download {url} -> {resp.status}: "
|
||||
f"{(await resp.text())[:300]}"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Infomaniak download error: {e}")
|
||||
logger.error(f"Infomaniak download {url} crashed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -75,11 +127,136 @@ def _unwrapData(payload: Any) -> Any:
|
|||
return payload
|
||||
|
||||
|
||||
def _firstOwnerRecord(payload: Any, listKey: str) -> Optional[Dict[str, Any]]:
|
||||
"""Pick the first user-owned record from a PIM list response.
|
||||
|
||||
Both PIM Calendar (``calendars``) and PIM Contacts (``addressbooks``)
|
||||
return ``{result, data: {<listKey>: [...]}}``. Owner-records have a
|
||||
positive numeric ``user_id`` and an integer ``account_id``; shared /
|
||||
public records (e.g. holiday calendars) carry ``user_id = -1`` and
|
||||
``account_id = null`` and are skipped.
|
||||
"""
|
||||
data = _unwrapData(payload) if payload else None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
records = data.get(listKey)
|
||||
if not isinstance(records, list):
|
||||
return None
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
userId = rec.get("user_id")
|
||||
accountId = rec.get("account_id")
|
||||
if isinstance(userId, int) and userId > 0 and isinstance(accountId, int):
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
async def resolveOwnerIdentity(token: str) -> InfomaniakOwnerIdentity:
|
||||
"""Derive the PAT owner's display identity from PIM Calendar / Contacts.
|
||||
|
||||
Used purely for UI display on the connection (``externalUsername`` /
|
||||
``externalId``). The PIM endpoints embed the kSuite ``account_id``
|
||||
and the user's display name in their owner records, which is what
|
||||
the ConnectionsPage shows.
|
||||
|
||||
Calendar is queried first because it is the more universally
|
||||
provisioned PIM service; Contacts is the equivalent fallback.
|
||||
Raises :class:`InfomaniakIdentityError` when neither yields an
|
||||
owner record.
|
||||
"""
|
||||
sources = (
|
||||
(_CALENDAR_BASE, f"{_PIM_PREFIX}/calendar", "calendars"),
|
||||
(_CONTACTS_BASE, f"{_PIM_PREFIX}/addressbook", "addressbooks"),
|
||||
)
|
||||
for baseUrl, endpoint, listKey in sources:
|
||||
payload = await _infomaniakGet(token, endpoint, baseUrl=baseUrl)
|
||||
if isinstance(payload, dict) and payload.get("error"):
|
||||
continue
|
||||
owner = _firstOwnerRecord(payload, listKey)
|
||||
if owner is None:
|
||||
continue
|
||||
return InfomaniakOwnerIdentity(
|
||||
accountId=int(owner["account_id"]),
|
||||
displayName=owner.get("name") or None,
|
||||
)
|
||||
raise InfomaniakIdentityError(
|
||||
"Could not resolve Infomaniak owner identity from PIM Calendar or "
|
||||
"Contacts. The PAT must carry 'workspace:calendar' or "
|
||||
"'workspace:contact' so we can label the connection."
|
||||
)
|
||||
|
||||
|
||||
async def resolveAccessibleAccountIds(token: str) -> List[int]:
|
||||
"""Return every Infomaniak account_id the PAT has access to.
|
||||
|
||||
Hits ``GET /1/accounts`` -- the only Infomaniak endpoint that lists
|
||||
*all* account_ids of a token in one call. Requires the PAT scope
|
||||
``accounts`` (Infomaniak responds 403 with
|
||||
``code: 'all_scopes', context: {scopes: ['accounts']}`` if missing).
|
||||
|
||||
The kSuite account_id from PIM (``resolveOwnerIdentity``) is **not**
|
||||
sufficient for kDrive: a standalone or free-tier kDrive lives on a
|
||||
different account_id than its kSuite counterpart. ``/2/drive`` is
|
||||
queried per account_id, so we resolve them all here and union the
|
||||
drive listings in :class:`KdriveAdapter`.
|
||||
|
||||
Raises :class:`InfomaniakIdentityError` when the PAT does not carry
|
||||
the ``accounts`` scope or the response is malformed.
|
||||
"""
|
||||
payload = await _infomaniakGet(token, "/1/accounts")
|
||||
if isinstance(payload, dict) and payload.get("error"):
|
||||
raise InfomaniakIdentityError(
|
||||
"Could not list Infomaniak accounts. The PAT must carry the "
|
||||
"'accounts' scope so kDrive can discover the owning account "
|
||||
f"(/1/accounts said: {payload['error']})."
|
||||
)
|
||||
data = _unwrapData(payload)
|
||||
if not isinstance(data, list):
|
||||
raise InfomaniakIdentityError(
|
||||
"Unexpected /1/accounts response shape (expected a list)."
|
||||
)
|
||||
accountIds: List[int] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
accountId = entry.get("id")
|
||||
if isinstance(accountId, int):
|
||||
accountIds.append(accountId)
|
||||
if not accountIds:
|
||||
raise InfomaniakIdentityError(
|
||||
"/1/accounts returned no accounts -- the PAT cannot reach any "
|
||||
"Infomaniak account."
|
||||
)
|
||||
return accountIds
|
||||
|
||||
|
||||
class KdriveAdapter(ServiceAdapter):
|
||||
"""kDrive ServiceAdapter -- browse drives, folders, and files."""
|
||||
"""kDrive ServiceAdapter -- browse drives, folders, files within all
|
||||
accounts the PAT can reach.
|
||||
|
||||
Infomaniak's ``/2/drive`` listing endpoint requires the integer
|
||||
``account_id`` of the *drive-owning* account as a query arg. A user
|
||||
may own kDrives in several accounts (typically a kSuite account
|
||||
plus a standalone / free-tier kDrive account), and the kSuite
|
||||
account_id from PIM does **not** cover the standalone case.
|
||||
|
||||
The only PAT-friendly way to enumerate every account_id is
|
||||
:func:`resolveAccessibleAccountIds` (``GET /1/accounts`` with the
|
||||
``accounts`` scope). This adapter therefore resolves the full
|
||||
account list once per instance and unions the ``/2/drive`` listing
|
||||
across all of them in :meth:`_listDrives`.
|
||||
"""
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self._token = accessToken
|
||||
self._accountIds: Optional[List[int]] = None
|
||||
|
||||
async def _ensureAccountIds(self) -> List[int]:
|
||||
if self._accountIds is not None:
|
||||
return self._accountIds
|
||||
self._accountIds = await resolveAccessibleAccountIds(self._token)
|
||||
return self._accountIds
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
|
|
@ -101,26 +278,41 @@ class KdriveAdapter(ServiceAdapter):
|
|||
return await self._listChildren(driveId, fileId=fileId, limit=limit)
|
||||
|
||||
async def _listDrives(self) -> List[ExternalEntry]:
|
||||
result = await _infomaniakGet(self._token, "/2/drive")
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"kDrive list-drives failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
drives = data.get("drives", {}).get("accounts", []) if isinstance(data, dict) else []
|
||||
if not drives and isinstance(data, list):
|
||||
drives = data
|
||||
accountIds = await self._ensureAccountIds()
|
||||
seen: set = set()
|
||||
entries: List[ExternalEntry] = []
|
||||
for drive in drives:
|
||||
driveId = str(drive.get("id", ""))
|
||||
if not driveId:
|
||||
for accountId in accountIds:
|
||||
result = await _infomaniakGet(
|
||||
self._token, f"/2/drive?account_id={accountId}"
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(
|
||||
f"kDrive list-drives for account {accountId} failed: {result['error']}"
|
||||
)
|
||||
continue
|
||||
name = drive.get("name") or driveId
|
||||
entries.append(ExternalEntry(
|
||||
name=name,
|
||||
path=f"/{driveId}",
|
||||
isFolder=True,
|
||||
metadata={"id": driveId, "kind": "drive"},
|
||||
))
|
||||
data = _unwrapData(result)
|
||||
drives: List[Dict[str, Any]]
|
||||
if isinstance(data, list):
|
||||
drives = [d for d in data if isinstance(d, dict)]
|
||||
elif isinstance(data, dict):
|
||||
drives = data.get("drives", {}).get("accounts", []) or []
|
||||
else:
|
||||
drives = []
|
||||
for drive in drives:
|
||||
driveId = str(drive.get("id", ""))
|
||||
if not driveId or driveId in seen:
|
||||
continue
|
||||
seen.add(driveId)
|
||||
entries.append(ExternalEntry(
|
||||
name=drive.get("name") or driveId,
|
||||
path=f"/{driveId}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"id": driveId,
|
||||
"kind": "drive",
|
||||
"accountId": accountId,
|
||||
},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def _listChildren(
|
||||
|
|
@ -129,9 +321,6 @@ class KdriveAdapter(ServiceAdapter):
|
|||
fileId: Optional[str],
|
||||
limit: Optional[int],
|
||||
) -> List[ExternalEntry]:
|
||||
# Infomaniak treats every folder (including drive root) as a file-id.
|
||||
# When fileId is None, we ask the drive for root children via the
|
||||
# documented `/files` collection endpoint.
|
||||
if fileId is None:
|
||||
endpoint = f"/2/drive/{driveId}/files"
|
||||
else:
|
||||
|
|
@ -142,7 +331,9 @@ class KdriveAdapter(ServiceAdapter):
|
|||
|
||||
result = await _infomaniakGet(self._token, endpoint)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}")
|
||||
logger.warning(
|
||||
f"kDrive list-children {driveId}/{fileId or 'root'} failed: {result['error']}"
|
||||
)
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
items = data if isinstance(data, list) else data.get("items", []) if isinstance(data, dict) else []
|
||||
|
|
@ -179,7 +370,9 @@ class KdriveAdapter(ServiceAdapter):
|
|||
fileName = data.get("name") or fileId
|
||||
mimeType = data.get("mime_type") or mimeType
|
||||
|
||||
content = await _infomaniakDownload(self._token, f"/2/drive/{driveId}/files/{fileId}/download")
|
||||
content = await _infomaniakDownload(
|
||||
self._token, f"/2/drive/{driveId}/files/{fileId}/download"
|
||||
)
|
||||
if content is None:
|
||||
return DownloadResult()
|
||||
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
|
||||
|
|
@ -227,11 +420,40 @@ class KdriveAdapter(ServiceAdapter):
|
|||
return entries
|
||||
|
||||
|
||||
class MailAdapter(ServiceAdapter):
|
||||
"""Infomaniak Mail ServiceAdapter -- browse mailboxes, folders and messages."""
|
||||
def _safeFileName(label: str, fallback: str) -> str:
|
||||
"""Sanitize a string for use as a filename. Trims and caps at 80 chars."""
|
||||
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(label or "")).strip(". ")
|
||||
return cleaned[:80] or fallback
|
||||
|
||||
_DEFAULT_MESSAGE_LIMIT = 100
|
||||
_MAX_MESSAGE_LIMIT = 500
|
||||
|
||||
class CalendarAdapter(ServiceAdapter):
|
||||
"""Infomaniak Calendar adapter -- browse calendars + events, .ics download.
|
||||
|
||||
Uses the public PIM endpoints at ``calendar.infomaniak.com/api/pim``,
|
||||
which authenticate with the PAT scope ``workspace:calendar``.
|
||||
|
||||
Path layout:
|
||||
``/`` -> list calendars
|
||||
``/{calendarId}`` -> list events of that calendar
|
||||
``/{calendarId}/{eventId}`` -> single event (download as .ics)
|
||||
|
||||
Endpoint particulars:
|
||||
Listing events runs against ``/api/pim/event`` with the calendar
|
||||
id as a query arg (the per-calendar nested route
|
||||
``/calendar/{id}/event`` is **not** PAT-friendly -- it 302s to the
|
||||
OAuth login page). Infomaniak enforces a hard ``from``/``to``
|
||||
window of less than 3 months, so this adapter queries a fixed
|
||||
90-day window centered on today (30 days back, 60 days forward),
|
||||
which covers typical UDB browsing. Date format is ``Y-m-d H:i:s``.
|
||||
Event detail and ``.ics`` export are addressed by event id alone
|
||||
(``/api/pim/event/{eventId}`` and ``.../export``); the calendar
|
||||
id from the path is kept only for tree-navigation continuity.
|
||||
"""
|
||||
|
||||
# Vendor enforces ``Range must be lower than 3 months``. We stay
|
||||
# comfortably below to keep one call per browse.
|
||||
_PAST_DAYS = 30
|
||||
_FUTURE_DAYS = 60
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self._token = accessToken
|
||||
|
|
@ -242,169 +464,461 @@ class MailAdapter(ServiceAdapter):
|
|||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
segments = [s for s in cleanPath.split("/") if s]
|
||||
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if not segments:
|
||||
return await self._listMailboxes()
|
||||
return await self._listCalendars()
|
||||
if len(segments) == 1:
|
||||
return await self._listFolders(segments[0])
|
||||
if len(segments) == 2:
|
||||
return await self._listMessages(segments[0], segments[1], limit=limit)
|
||||
return await self._listEvents(segments[0], limit=limit)
|
||||
return []
|
||||
|
||||
async def _listMailboxes(self) -> List[ExternalEntry]:
|
||||
result = await _infomaniakGet(self._token, "/1/mail")
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"Mail list-mailboxes failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
mailboxes = data if isinstance(data, list) else data.get("mailboxes", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
for mb in mailboxes:
|
||||
mbId = str(mb.get("id") or mb.get("mailbox_id") or "")
|
||||
if not mbId:
|
||||
continue
|
||||
entries.append(ExternalEntry(
|
||||
name=mb.get("email") or mb.get("name") or mbId,
|
||||
path=f"/{mbId}",
|
||||
isFolder=True,
|
||||
metadata={"id": mbId, "kind": "mailbox"},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def _listFolders(self, mailboxId: str) -> List[ExternalEntry]:
|
||||
result = await _infomaniakGet(self._token, f"/1/mail/{mailboxId}/folder")
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"Mail list-folders {mailboxId} failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
folders = data if isinstance(data, list) else data.get("folders", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
for f in folders:
|
||||
folderId = str(f.get("id") or f.get("path") or "")
|
||||
if not folderId:
|
||||
continue
|
||||
entries.append(ExternalEntry(
|
||||
name=f.get("name") or folderId,
|
||||
path=f"/{mailboxId}/{folderId}",
|
||||
isFolder=True,
|
||||
metadata={"id": folderId, "kind": "folder"},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def _listMessages(
|
||||
self,
|
||||
mailboxId: str,
|
||||
folderId: str,
|
||||
limit: Optional[int],
|
||||
) -> List[ExternalEntry]:
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(
|
||||
1, min(int(limit), self._MAX_MESSAGE_LIMIT),
|
||||
async def _listCalendars(self) -> List[ExternalEntry]:
|
||||
result = await _infomaniakGet(
|
||||
self._token, f"{_PIM_PREFIX}/calendar", baseUrl=_CALENDAR_BASE
|
||||
)
|
||||
endpoint = f"/1/mail/{mailboxId}/folder/{folderId}/message?per_page={effectiveLimit}"
|
||||
result = await _infomaniakGet(self._token, endpoint)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"Calendar list-calendars failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else []
|
||||
|
||||
calendars = data.get("calendars", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
for msg in messages:
|
||||
uid = str(msg.get("uid") or msg.get("id") or "")
|
||||
if not uid:
|
||||
for cal in calendars:
|
||||
calId = str(cal.get("id", ""))
|
||||
if not calId:
|
||||
continue
|
||||
subject = msg.get("subject") or "(no subject)"
|
||||
isShared = (cal.get("user_id") or 0) <= 0 or cal.get("account_id") is None
|
||||
entries.append(ExternalEntry(
|
||||
name=subject,
|
||||
path=f"/{mailboxId}/{folderId}/{uid}",
|
||||
isFolder=False,
|
||||
lastModified=msg.get("date") or msg.get("internal_date"),
|
||||
name=cal.get("name") or calId,
|
||||
path=f"/{calId}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"uid": uid,
|
||||
"from": msg.get("from") or msg.get("sender", ""),
|
||||
"snippet": msg.get("preview", ""),
|
||||
"id": calId,
|
||||
"kind": "calendar",
|
||||
"color": cal.get("color"),
|
||||
"shared": isShared,
|
||||
"default": bool(cal.get("default")),
|
||||
},
|
||||
))
|
||||
return entries
|
||||
|
||||
def _eventWindow(self) -> tuple:
|
||||
now = datetime.now(timezone.utc)
|
||||
fromStr = (now - timedelta(days=self._PAST_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
toStr = (now + timedelta(days=self._FUTURE_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return fromStr, toStr
|
||||
|
||||
async def _listEvents(
|
||||
self,
|
||||
calendarId: str,
|
||||
limit: Optional[int],
|
||||
) -> List[ExternalEntry]:
|
||||
fromStr, toStr = self._eventWindow()
|
||||
endpoint = (
|
||||
f"{_PIM_PREFIX}/event"
|
||||
f"?calendar_id={calendarId}"
|
||||
f"&from={quote(fromStr)}"
|
||||
f"&to={quote(toStr)}"
|
||||
)
|
||||
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CALENDAR_BASE)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"Calendar list-events {calendarId} failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
events = data if isinstance(data, list) else data.get("events", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
for ev in events:
|
||||
evId = str(ev.get("id") or ev.get("uid") or "")
|
||||
if not evId:
|
||||
continue
|
||||
title = ev.get("title") or ev.get("summary") or "(no title)"
|
||||
entries.append(ExternalEntry(
|
||||
name=title,
|
||||
path=f"/{calendarId}/{evId}",
|
||||
isFolder=False,
|
||||
metadata={
|
||||
"id": evId,
|
||||
"kind": "event",
|
||||
"start": ev.get("start"),
|
||||
"end": ev.get("end"),
|
||||
"location": ev.get("location"),
|
||||
"updated": ev.get("updated_at"),
|
||||
},
|
||||
))
|
||||
if limit is not None:
|
||||
return entries[: int(limit)]
|
||||
return entries
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
import re
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if len(segments) < 3:
|
||||
if len(segments) < 2:
|
||||
return DownloadResult()
|
||||
mailboxId, folderId, uid = segments[0], segments[1], segments[2]
|
||||
eventId = segments[1]
|
||||
|
||||
content = await _infomaniakDownload(
|
||||
self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}/download",
|
||||
self._token,
|
||||
f"{_PIM_PREFIX}/event/{eventId}/export",
|
||||
baseUrl=_CALENDAR_BASE,
|
||||
)
|
||||
if content is None:
|
||||
return DownloadResult()
|
||||
|
||||
title = eventId
|
||||
meta = await _infomaniakGet(
|
||||
self._token, f"/1/mail/{mailboxId}/folder/{folderId}/message/{uid}",
|
||||
self._token,
|
||||
f"{_PIM_PREFIX}/event/{eventId}",
|
||||
baseUrl=_CALENDAR_BASE,
|
||||
)
|
||||
subject = uid
|
||||
if isinstance(meta, dict) and not meta.get("error"):
|
||||
unwrapped = _unwrapData(meta)
|
||||
if isinstance(unwrapped, dict):
|
||||
subject = unwrapped.get("subject") or uid
|
||||
safeName = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", subject)[:80].strip(". ") or "email"
|
||||
event = unwrapped.get("event") if "event" in unwrapped else unwrapped
|
||||
if isinstance(event, dict):
|
||||
title = event.get("title") or event.get("summary") or eventId
|
||||
return DownloadResult(
|
||||
data=content,
|
||||
fileName=f"{safeName}.eml",
|
||||
mimeType="message/rfc822",
|
||||
fileName=f"{_safeFileName(title, 'event')}.ics",
|
||||
mimeType="text/calendar",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Mail upload not applicable"}
|
||||
return {"error": "Calendar upload not yet implemented"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
# The PIM Calendar API has no public search endpoint we can rely on.
|
||||
# Cheap fallback: list events in the current calendar (or all of
|
||||
# them) within the default window and filter case-insensitively on
|
||||
# title/location.
|
||||
calendars = (
|
||||
await self._listCalendars()
|
||||
if not path
|
||||
else [ExternalEntry(name="", path=path, isFolder=True)]
|
||||
)
|
||||
if not calendars:
|
||||
return []
|
||||
needle = (query or "").strip().lower()
|
||||
results: List[ExternalEntry] = []
|
||||
for cal in calendars:
|
||||
calId = (cal.metadata or {}).get("id") or cal.path.strip("/")
|
||||
for ev in await self._listEvents(calId, limit=limit):
|
||||
hay = " ".join(
|
||||
str(v) for v in (
|
||||
ev.name,
|
||||
(ev.metadata or {}).get("location") or "",
|
||||
)
|
||||
).lower()
|
||||
if not needle or needle in hay:
|
||||
results.append(ev)
|
||||
if limit is not None and len(results) >= int(limit):
|
||||
break
|
||||
return results[: int(limit)] if limit is not None else results
|
||||
|
||||
|
||||
def _vcardEscape(value: Any) -> str:
|
||||
"""Escape a value for vCard 3.0 -- backslash, comma, semicolon, newline."""
|
||||
text = "" if value is None else str(value)
|
||||
return (
|
||||
text.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
|
||||
def _renderInfomaniakVcard(record: Dict[str, Any]) -> str:
|
||||
"""Render an Infomaniak contact record as a vCard 3.0 string.
|
||||
|
||||
The Contacts PIM ``/contact/{id}/export`` endpoint is not PAT-friendly
|
||||
(302s to the OAuth login page), and ``/contact/{id}`` returns 500 with
|
||||
a PAT, so we cannot retrieve the canonical .vcf or detail blob from
|
||||
Infomaniak. Instead we synthesize a vCard 3.0 payload from the
|
||||
listing record fetched with ``with=emails,phones,addresses,details``.
|
||||
|
||||
vCard 3.0 is the common-denominator format universally accepted by
|
||||
Outlook, Google Contacts, Apple Contacts and Thunderbird (4.0 still
|
||||
has poor Outlook import compatibility).
|
||||
"""
|
||||
firstname = record.get("firstname") or ""
|
||||
lastname = record.get("lastname") or ""
|
||||
fullName = (
|
||||
record.get("name")
|
||||
or " ".join(p for p in (firstname, lastname) if p).strip()
|
||||
or "Contact"
|
||||
)
|
||||
organization = record.get("organization") or ""
|
||||
note = record.get("note") or ""
|
||||
emails = record.get("emails") or []
|
||||
phones = record.get("phones") or []
|
||||
addresses = record.get("addresses") or []
|
||||
websites = record.get("websites") or []
|
||||
|
||||
lines = ["BEGIN:VCARD", "VERSION:3.0"]
|
||||
# N: Last;First;Middle;Prefix;Suffix
|
||||
lines.append(f"N:{_vcardEscape(lastname)};{_vcardEscape(firstname)};;;")
|
||||
lines.append(f"FN:{_vcardEscape(fullName)}")
|
||||
if organization:
|
||||
lines.append(f"ORG:{_vcardEscape(organization)}")
|
||||
for email in emails:
|
||||
if isinstance(email, str) and email:
|
||||
lines.append(f"EMAIL;TYPE=INTERNET:{_vcardEscape(email)}")
|
||||
elif isinstance(email, dict) and email.get("address"):
|
||||
lines.append(f"EMAIL;TYPE=INTERNET:{_vcardEscape(email['address'])}")
|
||||
for phone in phones:
|
||||
if isinstance(phone, str) and phone:
|
||||
lines.append(f"TEL:{_vcardEscape(phone)}")
|
||||
elif isinstance(phone, dict) and phone.get("number"):
|
||||
lines.append(f"TEL:{_vcardEscape(phone['number'])}")
|
||||
for addr in addresses:
|
||||
if isinstance(addr, dict):
|
||||
# ADR: PO-Box;Extended;Street;City;Region;Postal;Country
|
||||
lines.append(
|
||||
"ADR:;;"
|
||||
f"{_vcardEscape(addr.get('street'))};"
|
||||
f"{_vcardEscape(addr.get('city'))};"
|
||||
f"{_vcardEscape(addr.get('region'))};"
|
||||
f"{_vcardEscape(addr.get('zip') or addr.get('postal_code'))};"
|
||||
f"{_vcardEscape(addr.get('country'))}"
|
||||
)
|
||||
for site in websites:
|
||||
if isinstance(site, str) and site:
|
||||
lines.append(f"URL:{_vcardEscape(site)}")
|
||||
elif isinstance(site, dict) and site.get("url"):
|
||||
lines.append(f"URL:{_vcardEscape(site['url'])}")
|
||||
if note:
|
||||
lines.append(f"NOTE:{_vcardEscape(note)}")
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
|
||||
class ContactAdapter(ServiceAdapter):
|
||||
"""Infomaniak Contacts adapter -- browse address books + contacts, .vcf download.
|
||||
|
||||
Uses the public PIM endpoint at ``contacts.infomaniak.com/api/pim``,
|
||||
which authenticates with the PAT scope ``workspace:contact``.
|
||||
|
||||
Path layout:
|
||||
``/`` -> list address books
|
||||
``/{addressBookId}`` -> list contacts in that book
|
||||
``/{addressBookId}/{contactId}`` -> single contact (download as .vcf)
|
||||
|
||||
Endpoint particulars:
|
||||
Listing both address books and contacts is PAT-friendly. The
|
||||
contact-listing call uses ``with=emails,phones,addresses,details``
|
||||
so each record arrives with all the fields needed for vCard
|
||||
synthesis -- Infomaniak skips them by default. Detail and export
|
||||
endpoints (``/contact/{id}``, ``/contact/{id}/export``) are **not**
|
||||
PAT-friendly (the former 500s, the latter 302s to OAuth), so the
|
||||
``download`` path re-fetches the listing and renders the vCard
|
||||
ourselves via :func:`_renderInfomaniakVcard`.
|
||||
"""
|
||||
|
||||
_DEFAULT_CONTACT_LIMIT = 200
|
||||
_MAX_CONTACT_LIMIT = 1000
|
||||
_CONTACT_FIELDS = "emails,phones,addresses,details"
|
||||
|
||||
def __init__(self, accessToken: str):
|
||||
self._token = accessToken
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
path: str,
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if not segments:
|
||||
mailboxes = await self._listMailboxes()
|
||||
if not mailboxes:
|
||||
return []
|
||||
mailboxId = (mailboxes[0].metadata or {}).get("id") or mailboxes[0].path.strip("/")
|
||||
else:
|
||||
mailboxId = segments[0]
|
||||
return await self._listAddressBooks()
|
||||
if len(segments) == 1:
|
||||
return await self._listContacts(segments[0], limit=limit)
|
||||
return []
|
||||
|
||||
effectiveLimit = self._DEFAULT_MESSAGE_LIMIT if limit is None else max(
|
||||
1, min(int(limit), self._MAX_MESSAGE_LIMIT),
|
||||
async def _listAddressBooks(self) -> List[ExternalEntry]:
|
||||
result = await _infomaniakGet(
|
||||
self._token, f"{_PIM_PREFIX}/addressbook", baseUrl=_CONTACTS_BASE
|
||||
)
|
||||
endpoint = f"/1/mail/{mailboxId}/message/search?query={query}&per_page={effectiveLimit}"
|
||||
result = await _infomaniakGet(self._token, endpoint)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(f"Contacts list-addressbooks failed: {result['error']}")
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
messages = data if isinstance(data, list) else data.get("messages", []) if isinstance(data, dict) else []
|
||||
|
||||
books = data.get("addressbooks", []) if isinstance(data, dict) else []
|
||||
entries: List[ExternalEntry] = []
|
||||
for msg in messages:
|
||||
uid = str(msg.get("uid") or msg.get("id") or "")
|
||||
if not uid:
|
||||
for book in books:
|
||||
bookId = str(book.get("id", ""))
|
||||
if not bookId:
|
||||
continue
|
||||
folderId = str(msg.get("folder_id") or msg.get("folderId") or "")
|
||||
isShared = bool(book.get("is_shared")) or (book.get("user_id") or 0) <= 0
|
||||
# The shared organisation directory has an empty name -- give it a
|
||||
# human label so the UDB tree is not blank.
|
||||
name = book.get("name") or (
|
||||
"Organisation" if book.get("is_dynamic_organisation_member_directory") else bookId
|
||||
)
|
||||
entries.append(ExternalEntry(
|
||||
name=msg.get("subject") or uid,
|
||||
path=f"/{mailboxId}/{folderId}/{uid}" if folderId else f"/{mailboxId}/{uid}",
|
||||
isFolder=False,
|
||||
metadata={"uid": uid, "from": msg.get("from", "")},
|
||||
name=name,
|
||||
path=f"/{bookId}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"id": bookId,
|
||||
"kind": "addressbook",
|
||||
"color": book.get("color"),
|
||||
"shared": isShared,
|
||||
"default": bool(book.get("default")),
|
||||
},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def _fetchContacts(
|
||||
self,
|
||||
addressBookId: str,
|
||||
perPage: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Raw listing call -- shared by browse and download."""
|
||||
endpoint = (
|
||||
f"{_PIM_PREFIX}/addressbook/{addressBookId}/contact"
|
||||
f"?per_page={perPage}&with={self._CONTACT_FIELDS}"
|
||||
)
|
||||
result = await _infomaniakGet(self._token, endpoint, baseUrl=_CONTACTS_BASE)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
logger.warning(
|
||||
f"Contacts list-contacts {addressBookId} failed: {result['error']}"
|
||||
)
|
||||
return []
|
||||
data = _unwrapData(result)
|
||||
if isinstance(data, list):
|
||||
return [c for c in data if isinstance(c, dict)]
|
||||
if isinstance(data, dict):
|
||||
contacts = data.get("contacts", [])
|
||||
return [c for c in contacts if isinstance(c, dict)]
|
||||
return []
|
||||
|
||||
async def _listContacts(
|
||||
self,
|
||||
addressBookId: str,
|
||||
limit: Optional[int],
|
||||
) -> List[ExternalEntry]:
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(
|
||||
1, min(int(limit), self._MAX_CONTACT_LIMIT),
|
||||
)
|
||||
contacts = await self._fetchContacts(addressBookId, perPage=effectiveLimit)
|
||||
entries: List[ExternalEntry] = []
|
||||
for c in contacts:
|
||||
cId = str(c.get("id") or c.get("uid") or "")
|
||||
if not cId:
|
||||
continue
|
||||
firstName = c.get("firstname")
|
||||
lastName = c.get("lastname")
|
||||
displayName = (
|
||||
c.get("name")
|
||||
or " ".join(p for p in (firstName, lastName) if p).strip()
|
||||
or (c.get("emails") or [None])[0]
|
||||
or cId
|
||||
)
|
||||
firstEmail = (c.get("emails") or [None])[0]
|
||||
firstPhone = (c.get("phones") or [None])[0]
|
||||
entries.append(ExternalEntry(
|
||||
name=str(displayName),
|
||||
path=f"/{addressBookId}/{cId}",
|
||||
isFolder=False,
|
||||
metadata={
|
||||
"id": cId,
|
||||
"kind": "contact",
|
||||
"email": firstEmail,
|
||||
"phone": firstPhone,
|
||||
"organization": c.get("organization"),
|
||||
},
|
||||
))
|
||||
return entries
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if len(segments) < 2:
|
||||
return DownloadResult()
|
||||
addressBookId, contactId = segments[0], segments[1]
|
||||
|
||||
# The PIM contact-detail endpoint (``/contact/{id}``) returns 500
|
||||
# against a PAT, and ``/contact/{id}/export`` 302s to OAuth. We
|
||||
# therefore re-fetch the listing (which IS PAT-friendly) with all
|
||||
# vCard-relevant fields, then synthesize the .vcf ourselves.
|
||||
contacts = await self._fetchContacts(
|
||||
addressBookId, perPage=self._MAX_CONTACT_LIMIT
|
||||
)
|
||||
record = next((c for c in contacts if str(c.get("id")) == contactId), None)
|
||||
if record is None:
|
||||
logger.warning(
|
||||
f"Contacts download: contact {contactId} not found in book "
|
||||
f"{addressBookId}"
|
||||
)
|
||||
return DownloadResult()
|
||||
|
||||
firstName = record.get("firstname") or ""
|
||||
lastName = record.get("lastname") or ""
|
||||
displayName = (
|
||||
record.get("name")
|
||||
or " ".join(p for p in (firstName, lastName) if p).strip()
|
||||
or contactId
|
||||
)
|
||||
vcardText = _renderInfomaniakVcard(record)
|
||||
return DownloadResult(
|
||||
data=vcardText.encode("utf-8"),
|
||||
fileName=f"{_safeFileName(displayName, 'contact')}.vcf",
|
||||
mimeType="text/vcard",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Contacts upload not yet implemented"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
# No public search endpoint -- list contacts of the current (or all)
|
||||
# address books and filter client-side on display name / email.
|
||||
books = (
|
||||
await self._listAddressBooks()
|
||||
if not path
|
||||
else [ExternalEntry(name="", path=path, isFolder=True)]
|
||||
)
|
||||
if not books:
|
||||
return []
|
||||
needle = (query or "").strip().lower()
|
||||
results: List[ExternalEntry] = []
|
||||
for book in books:
|
||||
bookId = (book.metadata or {}).get("id") or book.path.strip("/")
|
||||
for c in await self._listContacts(bookId, limit=limit):
|
||||
hay = " ".join(
|
||||
str(v) for v in (
|
||||
c.name,
|
||||
(c.metadata or {}).get("email") or "",
|
||||
(c.metadata or {}).get("organization") or "",
|
||||
)
|
||||
).lower()
|
||||
if not needle or needle in hay:
|
||||
results.append(c)
|
||||
if limit is not None and len(results) >= int(limit):
|
||||
break
|
||||
return results[: int(limit)] if limit is not None else results
|
||||
|
||||
|
||||
class InfomaniakConnector(ProviderConnector):
|
||||
"""Infomaniak ProviderConnector -- 1 connection -> kDrive + Mail."""
|
||||
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts today.
|
||||
|
||||
Mail is reserved on the PAT (scope ``workspace:mail``) but not wired
|
||||
up here yet -- Infomaniak has no public PAT-friendly Mail endpoint
|
||||
today (the PIM Mail routes 302 to OAuth, the legacy ``/api/mail`` route
|
||||
301-redirects to an internal Cyrus port). Once a working endpoint is
|
||||
found, the corresponding adapter can be slotted into ``_SERVICE_MAP``
|
||||
without any token rotation on the user side.
|
||||
"""
|
||||
|
||||
_SERVICE_MAP = {
|
||||
"kdrive": KdriveAdapter,
|
||||
"mail": MailAdapter,
|
||||
"calendar": CalendarAdapter,
|
||||
"contact": ContactAdapter,
|
||||
}
|
||||
|
||||
def getAvailableServices(self) -> List[str]:
|
||||
|
|
|
|||
|
|
@ -841,6 +841,285 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendar Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
||||
"""ServiceAdapter for Outlook Calendar via Microsoft Graph.
|
||||
|
||||
Path conventions:
|
||||
``""`` / ``"/"`` -> list user calendars
|
||||
``"/<calendarId>"`` -> list events in that calendar
|
||||
``"/<calendarId>/<eventId>"`` -> reserved for future event detail browse
|
||||
|
||||
Downloads return a synthesised ``.ics`` (VCALENDAR/VEVENT) since Microsoft
|
||||
Graph does not expose a ``/$value`` endpoint for events.
|
||||
"""
|
||||
|
||||
_DEFAULT_EVENT_LIMIT = 100
|
||||
_MAX_EVENT_LIMIT = 1000
|
||||
_PAGE_SIZE = 100
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
path: str,
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
result = await self._graphGet("me/calendars?$top=100")
|
||||
if "error" in result:
|
||||
logger.warning(f"MSFT Calendar list failed: {result['error']}")
|
||||
return []
|
||||
calendars = result.get("value", [])
|
||||
if filter:
|
||||
calendars = [c for c in calendars if filter.lower() in (c.get("name") or "").lower()]
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=c.get("name", ""),
|
||||
path=f"/{c.get('id', '')}",
|
||||
isFolder=True,
|
||||
metadata={
|
||||
"id": c.get("id"),
|
||||
"color": c.get("color"),
|
||||
"owner": (c.get("owner") or {}).get("address"),
|
||||
"isDefaultCalendar": c.get("isDefaultCalendar", False),
|
||||
"canEdit": c.get("canEdit", False),
|
||||
},
|
||||
)
|
||||
for c in calendars
|
||||
]
|
||||
|
||||
calendarId = cleanPath.split("/", 1)[0]
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||
endpoint: Optional[str] = (
|
||||
f"me/calendars/{calendarId}/events"
|
||||
f"?$top={pageSize}&$orderby=start/dateTime desc"
|
||||
)
|
||||
events: List[Dict[str, Any]] = []
|
||||
while endpoint and len(events) < effectiveLimit:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
logger.warning(f"MSFT Calendar events failed: {result['error']}")
|
||||
break
|
||||
for ev in result.get("value", []):
|
||||
events.append(ev)
|
||||
if len(events) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("subject", "(no subject)"),
|
||||
path=f"/{calendarId}/{ev.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/calendar",
|
||||
metadata={
|
||||
"id": ev.get("id"),
|
||||
"start": (ev.get("start") or {}).get("dateTime"),
|
||||
"end": (ev.get("end") or {}).get("dateTime"),
|
||||
"location": (ev.get("location") or {}).get("displayName"),
|
||||
"organizer": (ev.get("organizer") or {}).get("emailAddress", {}).get("address"),
|
||||
"isAllDay": ev.get("isAllDay", False),
|
||||
"webLink": ev.get("webLink"),
|
||||
},
|
||||
)
|
||||
for ev in events
|
||||
]
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
return DownloadResult()
|
||||
eventId = cleanPath.split("/")[-1]
|
||||
ev = await self._graphGet(f"me/events/{eventId}")
|
||||
if "error" in ev:
|
||||
logger.warning(f"MSFT Calendar event fetch failed: {ev['error']}")
|
||||
return DownloadResult()
|
||||
icsBytes = _eventToIcs(ev)
|
||||
subject = ev.get("subject") or eventId
|
||||
safeName = _safeFileName(subject) or "event"
|
||||
return DownloadResult(
|
||||
data=icsBytes,
|
||||
fileName=f"{safeName}.ics",
|
||||
mimeType="text/calendar",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Calendar upload not supported"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace("'", "''")
|
||||
effectiveLimit = self._DEFAULT_EVENT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_EVENT_LIMIT))
|
||||
endpoint = f"me/events?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=ev.get("subject", "(no subject)"),
|
||||
path=f"/search/{ev.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/calendar",
|
||||
metadata={
|
||||
"id": ev.get("id"),
|
||||
"start": (ev.get("start") or {}).get("dateTime"),
|
||||
"end": (ev.get("end") or {}).get("dateTime"),
|
||||
},
|
||||
)
|
||||
for ev in result.get("value", [])
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contacts Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
||||
"""ServiceAdapter for Outlook Contacts via Microsoft Graph.
|
||||
|
||||
Path conventions:
|
||||
``""`` -> list contact folders (default + custom)
|
||||
``"/<folderId>"`` -> list contacts in that folder; the
|
||||
virtual id ``default`` maps to
|
||||
``/me/contacts`` (the user's primary
|
||||
contact list)
|
||||
``"/<folderId>/<contactId>"`` -> reserved for future detail browse
|
||||
|
||||
Downloads return a synthesised vCard 3.0 (.vcf) since Microsoft Graph
|
||||
does not expose a ``/$value`` endpoint for contacts.
|
||||
"""
|
||||
|
||||
_DEFAULT_CONTACT_LIMIT = 200
|
||||
_MAX_CONTACT_LIMIT = 1000
|
||||
_PAGE_SIZE = 100
|
||||
_DEFAULT_FOLDER_ID = "default"
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
path: str,
|
||||
filter: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
folders: List[ExternalEntry] = [
|
||||
ExternalEntry(
|
||||
name="Kontakte",
|
||||
path=f"/{self._DEFAULT_FOLDER_ID}",
|
||||
isFolder=True,
|
||||
metadata={"id": self._DEFAULT_FOLDER_ID, "isDefault": True},
|
||||
),
|
||||
]
|
||||
result = await self._graphGet("me/contactFolders?$top=100")
|
||||
if "error" not in result:
|
||||
for f in result.get("value", []):
|
||||
folders.append(
|
||||
ExternalEntry(
|
||||
name=f.get("displayName", ""),
|
||||
path=f"/{f.get('id', '')}",
|
||||
isFolder=True,
|
||||
metadata={"id": f.get("id"), "parentFolderId": f.get("parentFolderId")},
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(f"MSFT contactFolders list failed: {result['error']}")
|
||||
return folders
|
||||
|
||||
folderId = cleanPath.split("/", 1)[0]
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
pageSize = min(self._PAGE_SIZE, effectiveLimit)
|
||||
if folderId == self._DEFAULT_FOLDER_ID:
|
||||
endpoint: Optional[str] = f"me/contacts?$top={pageSize}&$orderby=displayName"
|
||||
else:
|
||||
endpoint = f"me/contactFolders/{folderId}/contacts?$top={pageSize}&$orderby=displayName"
|
||||
|
||||
contacts: List[Dict[str, Any]] = []
|
||||
while endpoint and len(contacts) < effectiveLimit:
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
logger.warning(f"MSFT contacts list failed: {result['error']}")
|
||||
break
|
||||
for c in result.get("value", []):
|
||||
contacts.append(c)
|
||||
if len(contacts) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=c.get("displayName") or _personLabel(c) or "(no name)",
|
||||
path=f"/{folderId}/{c.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/vcard",
|
||||
metadata={
|
||||
"id": c.get("id"),
|
||||
"givenName": c.get("givenName"),
|
||||
"surname": c.get("surname"),
|
||||
"companyName": c.get("companyName"),
|
||||
"emailAddresses": [e.get("address") for e in (c.get("emailAddresses") or []) if e.get("address")],
|
||||
"businessPhones": c.get("businessPhones") or [],
|
||||
"mobilePhone": c.get("mobilePhone"),
|
||||
},
|
||||
)
|
||||
for c in contacts
|
||||
]
|
||||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
cleanPath = (path or "").strip("/")
|
||||
if "/" not in cleanPath:
|
||||
return DownloadResult()
|
||||
contactId = cleanPath.split("/")[-1]
|
||||
c = await self._graphGet(f"me/contacts/{contactId}")
|
||||
if "error" in c:
|
||||
logger.warning(f"MSFT contact fetch failed: {c['error']}")
|
||||
return DownloadResult()
|
||||
vcfBytes = _contactToVcard(c)
|
||||
label = c.get("displayName") or _personLabel(c) or contactId
|
||||
safeName = _safeFileName(label) or "contact"
|
||||
return DownloadResult(
|
||||
data=vcfBytes,
|
||||
fileName=f"{safeName}.vcf",
|
||||
mimeType="text/vcard",
|
||||
)
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
return {"error": "Contacts upload not supported"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
path: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ExternalEntry]:
|
||||
safeQuery = query.replace("'", "''")
|
||||
effectiveLimit = self._DEFAULT_CONTACT_LIMIT if limit is None else max(1, min(int(limit), self._MAX_CONTACT_LIMIT))
|
||||
endpoint = f"me/contacts?$search=\"{safeQuery}\"&$top={effectiveLimit}"
|
||||
result = await self._graphGet(endpoint)
|
||||
if "error" in result:
|
||||
return []
|
||||
return [
|
||||
ExternalEntry(
|
||||
name=c.get("displayName") or _personLabel(c) or "(no name)",
|
||||
path=f"/search/{c.get('id', '')}",
|
||||
isFolder=False,
|
||||
mimeType="text/vcard",
|
||||
metadata={"id": c.get("id")},
|
||||
)
|
||||
for c in result.get("value", [])
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MsftConnector (1:n)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -853,6 +1132,8 @@ class MsftConnector(ProviderConnector):
|
|||
"outlook": OutlookAdapter,
|
||||
"teams": TeamsAdapter,
|
||||
"onedrive": OneDriveAdapter,
|
||||
"calendar": CalendarAdapter,
|
||||
"contact": ContactsAdapter,
|
||||
}
|
||||
|
||||
def getAvailableServices(self) -> List[str]:
|
||||
|
|
@ -891,3 +1172,143 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
|
|||
"""Simple glob-like filter (supports * wildcard)."""
|
||||
import fnmatch
|
||||
return fnmatch.fnmatch(entry.name.lower(), pattern.lower())
|
||||
|
||||
|
||||
def _safeFileName(name: str) -> str:
|
||||
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
|
||||
import re
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||
|
||||
|
||||
def _personLabel(contact: Dict[str, Any]) -> str:
|
||||
given = (contact.get("givenName") or "").strip()
|
||||
surname = (contact.get("surname") or "").strip()
|
||||
if given or surname:
|
||||
return f"{given} {surname}".strip()
|
||||
company = (contact.get("companyName") or "").strip()
|
||||
return company
|
||||
|
||||
|
||||
def _icsEscape(value: str) -> str:
|
||||
"""Escape RFC 5545 reserved characters in TEXT properties."""
|
||||
if value is None:
|
||||
return ""
|
||||
return (
|
||||
value.replace("\\", "\\\\")
|
||||
.replace(";", "\\;")
|
||||
.replace(",", "\\,")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n")
|
||||
)
|
||||
|
||||
|
||||
def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
||||
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _eventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
|
||||
from datetime import datetime, timezone
|
||||
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
|
||||
summary = _icsEscape(event.get("subject") or "")
|
||||
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
|
||||
body = (event.get("body") or {}).get("content") or ""
|
||||
description = _icsEscape(body)
|
||||
dtstart = _icsDateTime((event.get("start") or {}).get("dateTime"))
|
||||
dtend = _icsDateTime((event.get("end") or {}).get("dateTime"))
|
||||
dtstamp = _icsDateTime(event.get("lastModifiedDateTime")) or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//PowerOn//MSFT-Calendar-Adapter//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT",
|
||||
f"UID:{uid}",
|
||||
f"DTSTAMP:{dtstamp}",
|
||||
]
|
||||
if dtstart:
|
||||
lines.append(f"DTSTART:{dtstart}")
|
||||
if dtend:
|
||||
lines.append(f"DTEND:{dtend}")
|
||||
if summary:
|
||||
lines.append(f"SUMMARY:{summary}")
|
||||
if location:
|
||||
lines.append(f"LOCATION:{location}")
|
||||
if description:
|
||||
lines.append(f"DESCRIPTION:{description}")
|
||||
organizer = (event.get("organizer") or {}).get("emailAddress", {}).get("address")
|
||||
if organizer:
|
||||
lines.append(f"ORGANIZER:mailto:{organizer}")
|
||||
for att in (event.get("attendees") or []):
|
||||
addr = (att.get("emailAddress") or {}).get("address")
|
||||
if addr:
|
||||
lines.append(f"ATTENDEE:mailto:{addr}")
|
||||
lines.append("END:VEVENT")
|
||||
lines.append("END:VCALENDAR")
|
||||
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
||||
|
||||
|
||||
def _contactToVcard(contact: Dict[str, Any]) -> bytes:
|
||||
"""Build a vCard 3.0 from a Graph /me/contacts payload."""
|
||||
given = contact.get("givenName") or ""
|
||||
surname = contact.get("surname") or ""
|
||||
middle = contact.get("middleName") or ""
|
||||
fn = contact.get("displayName") or _personLabel(contact) or contact.get("companyName") or ""
|
||||
|
||||
lines = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:3.0",
|
||||
f"N:{surname};{given};{middle};;",
|
||||
f"FN:{fn}",
|
||||
]
|
||||
if contact.get("companyName"):
|
||||
org = contact["companyName"]
|
||||
if contact.get("department"):
|
||||
org = f"{org};{contact['department']}"
|
||||
lines.append(f"ORG:{org}")
|
||||
if contact.get("jobTitle"):
|
||||
lines.append(f"TITLE:{contact['jobTitle']}")
|
||||
for em in (contact.get("emailAddresses") or []):
|
||||
addr = em.get("address")
|
||||
if addr:
|
||||
lines.append(f"EMAIL;TYPE=INTERNET:{addr}")
|
||||
for phone in (contact.get("businessPhones") or []):
|
||||
if phone:
|
||||
lines.append(f"TEL;TYPE=WORK,VOICE:{phone}")
|
||||
if contact.get("mobilePhone"):
|
||||
lines.append(f"TEL;TYPE=CELL,VOICE:{contact['mobilePhone']}")
|
||||
for phone in (contact.get("homePhones") or []):
|
||||
if phone:
|
||||
lines.append(f"TEL;TYPE=HOME,VOICE:{phone}")
|
||||
|
||||
def _appendAddress(addr: Dict[str, Any], typ: str) -> None:
|
||||
if not addr:
|
||||
return
|
||||
street = addr.get("street") or ""
|
||||
city = addr.get("city") or ""
|
||||
state = addr.get("state") or ""
|
||||
postal = addr.get("postalCode") or ""
|
||||
country = addr.get("countryOrRegion") or ""
|
||||
if any([street, city, state, postal, country]):
|
||||
lines.append(f"ADR;TYPE={typ}:;;{street};{city};{state};{postal};{country}")
|
||||
|
||||
_appendAddress(contact.get("businessAddress") or {}, "WORK")
|
||||
_appendAddress(contact.get("homeAddress") or {}, "HOME")
|
||||
_appendAddress(contact.get("otherAddress") or {}, "OTHER")
|
||||
if contact.get("personalNotes"):
|
||||
lines.append(f"NOTE:{_icsEscape(contact['personalNotes'])}")
|
||||
lines.append(f"UID:{contact.get('id', '')}")
|
||||
lines.append("END:VCARD")
|
||||
return ("\r\n".join(lines) + "\r\n").encode("utf-8")
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,9 @@ async def list_connection_services(
|
|||
"drive": "Google Drive",
|
||||
"gmail": "Gmail",
|
||||
"files": "Files (FTP)",
|
||||
"kdrive": "kDrive",
|
||||
"calendar": "Calendar",
|
||||
"contact": "Contacts",
|
||||
}
|
||||
_serviceIcons = {
|
||||
"sharepoint": "sharepoint",
|
||||
|
|
@ -1170,6 +1173,9 @@ async def list_connection_services(
|
|||
"drive": "cloud",
|
||||
"gmail": "mail",
|
||||
"files": "folder",
|
||||
"kdrive": "cloud",
|
||||
"calendar": "calendar",
|
||||
"contact": "contact",
|
||||
}
|
||||
items = [
|
||||
{"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")}
|
||||
|
|
|
|||
|
|
@ -1818,6 +1818,9 @@ async def listConnectionServices(
|
|||
"drive": "Google Drive",
|
||||
"gmail": "Gmail",
|
||||
"files": "Files (FTP)",
|
||||
"kdrive": "kDrive",
|
||||
"calendar": "Calendar",
|
||||
"contact": "Contacts",
|
||||
}
|
||||
_serviceIcons = {
|
||||
"sharepoint": "sharepoint",
|
||||
|
|
@ -1827,6 +1830,9 @@ async def listConnectionServices(
|
|||
"drive": "cloud",
|
||||
"gmail": "mail",
|
||||
"files": "folder",
|
||||
"kdrive": "cloud",
|
||||
"calendar": "calendar",
|
||||
"contact": "contact",
|
||||
}
|
||||
items = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3331,7 +3331,10 @@ class AppObjects:
|
|||
)
|
||||
|
||||
if not tokens:
|
||||
logger.warning(
|
||||
# Pending connections legitimately have no token yet (PAT not
|
||||
# submitted, OAuth callback not completed). Keep at DEBUG to
|
||||
# avoid noisy warnings on every connection-list refresh.
|
||||
logger.debug(
|
||||
f"No connection token found for connectionId: {connectionId}"
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -484,9 +484,16 @@ def update_connection(
|
|||
def connect_service(
|
||||
request: Request,
|
||||
connectionId: str = Path(..., description="The ID of the connection to connect"),
|
||||
body: Optional[Dict[str, Any]] = Body(default=None),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
) -> Dict[str, Any]:
|
||||
"""Connect a service for the current user
|
||||
"""Connect a service for the current user.
|
||||
|
||||
Optional body: ``{"reauth": true}`` -- forces the OAuth provider to re-show
|
||||
the consent screen, which is required when new scopes have been added (e.g.
|
||||
Calendar + Contacts after the connection was first created). Without this
|
||||
flag the provider silently re-uses the previous consent and never grants
|
||||
the new scopes, leaving the connection in a degraded state.
|
||||
|
||||
SECURITY: This endpoint is secure - users can only connect their own connections.
|
||||
"""
|
||||
|
|
@ -510,16 +517,27 @@ def connect_service(
|
|||
detail=routeApiMsg("Connection not found")
|
||||
)
|
||||
|
||||
reauth = bool((body or {}).get("reauth")) if isinstance(body, dict) else False
|
||||
reauthSuffix = "&reauth=1" if reauth else ""
|
||||
|
||||
# Data-app OAuth (JWT state issued server-side in /auth/connect)
|
||||
auth_url = None
|
||||
if connection.authority == AuthAuthority.MSFT:
|
||||
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
||||
elif connection.authority == AuthAuthority.GOOGLE:
|
||||
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
||||
elif connection.authority == AuthAuthority.CLICKUP:
|
||||
auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}{reauthSuffix}"
|
||||
elif connection.authority == AuthAuthority.INFOMANIAK:
|
||||
auth_url = f"/api/infomaniak/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
# Infomaniak does not use OAuth for data access; the frontend posts a
|
||||
# Personal Access Token directly to /api/infomaniak/connections/{id}/token.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=routeApiMsg(
|
||||
"Infomaniak uses a Personal Access Token instead of OAuth. "
|
||||
"Submit the token via POST /api/infomaniak/connections/{connectionId}/token."
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -281,9 +281,17 @@ async def auth_login_callback(
|
|||
def auth_connect(
|
||||
request: Request,
|
||||
connectionId: str = Query(..., description="UserConnection id"),
|
||||
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> RedirectResponse:
|
||||
"""Start Google Data OAuth for an existing connection (requires gateway session)."""
|
||||
"""Start Google Data OAuth for an existing connection (requires gateway session).
|
||||
|
||||
Google already defaults to ``prompt=consent`` here, but ``include_granted_scopes=true``
|
||||
can cause newly added scopes (e.g. calendar.readonly, contacts.readonly) to be
|
||||
silently dropped on subsequent re-authorisations. With ``reauth=1`` we drop
|
||||
``include_granted_scopes`` so Google re-issues a token strictly for the
|
||||
current scope list.
|
||||
"""
|
||||
try:
|
||||
_require_google_data_config()
|
||||
interface = getInterface(currentUser)
|
||||
|
|
@ -310,9 +318,10 @@ def auth_connect(
|
|||
)
|
||||
extra_params: Dict[str, Any] = {
|
||||
"access_type": "offline",
|
||||
"include_granted_scopes": "true",
|
||||
"state": state_jwt,
|
||||
}
|
||||
if not reauth:
|
||||
extra_params["include_granted_scopes"] = "true"
|
||||
login_hint = connection.externalEmail or connection.externalUsername
|
||||
if login_hint:
|
||||
extra_params["login_hint"] = login_hint
|
||||
|
|
|
|||
|
|
@ -1,69 +1,72 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak OAuth for data connections (UserConnection + Token).
|
||||
"""Infomaniak Personal-Access-Token onboarding for data connections.
|
||||
|
||||
Pure DATA_CONNECTION flow -- Infomaniak is NOT a login authority for PowerOn.
|
||||
Infomaniak does NOT support OAuth scopes for kDrive/kSuite data access.
|
||||
The user must create a Personal Access Token (PAT) at
|
||||
https://manager.infomaniak.com/v3/ng/accounts/token/list with the API
|
||||
scopes:
|
||||
|
||||
- ``accounts`` -> account discovery (REQUIRED for kDrive)
|
||||
- ``drive`` -> kDrive (active adapter)
|
||||
- ``workspace:calendar`` -> Calendar (active adapter)
|
||||
- ``workspace:contact`` -> Contacts (active adapter)
|
||||
- ``workspace:mail`` -> Mail (adapter pending; scope reserved)
|
||||
|
||||
Validation strategy
|
||||
-------------------
|
||||
The submit endpoint validates the PAT in three deterministic steps,
|
||||
each addressing exactly one scope:
|
||||
|
||||
1. ``resolveAccessibleAccountIds(pat)`` -> ``GET /1/accounts`` proves
|
||||
the ``accounts`` scope is on the PAT. Without this scope, kDrive
|
||||
cannot enumerate the owning account_ids (a standalone or free-tier
|
||||
kDrive lives on a *different* account_id than its kSuite
|
||||
counterpart, so the kSuite account_id from PIM is not enough).
|
||||
|
||||
2. ``resolveOwnerIdentity(pat)`` -> PIM Calendar (preferred) or PIM
|
||||
Contacts (fallback) yields the user's display name + their kSuite
|
||||
account_id, used purely for connection labelling. This also proves
|
||||
that at least one of ``workspace:calendar`` / ``workspace:contact``
|
||||
is on the PAT (the connection would otherwise be blank in the UI).
|
||||
|
||||
3. ``GET /2/drive?account_id={firstAccountId}`` is the final scope
|
||||
probe -- 200 means the ``drive`` scope is present. 401/403 means
|
||||
the scope is missing.
|
||||
|
||||
Mail has no separate probe: its scope is recorded in ``grantedScopes``
|
||||
so a future adapter can pick it up without re-issuing the token.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi import APIRouter, HTTPException, Request, status, Depends, Path, Body
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import urlencode
|
||||
import hashlib
|
||||
import httpx
|
||||
from jose import jwt as jose_jwt
|
||||
from jose import JWTError
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
from modules.interfaces.interfaceDbApp import getInterface
|
||||
from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection
|
||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
||||
from modules.auth.oauthProviderConfig import infomaniakDataScopes
|
||||
from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp
|
||||
from modules.auth import getCurrentUser, limiter
|
||||
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp
|
||||
from modules.shared.i18nRegistry import apiRouteContext
|
||||
from modules.connectors.providerInfomaniak.connectorInfomaniak import (
|
||||
resolveOwnerIdentity,
|
||||
resolveAccessibleAccountIds,
|
||||
InfomaniakIdentityError,
|
||||
)
|
||||
|
||||
routeApiMsg = apiRouteContext("routeSecurityInfomaniak")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_FLOW_CONNECT = "infomaniak_connect"
|
||||
|
||||
INFOMANIAK_AUTHORIZE_URL = "https://login.infomaniak.com/authorize"
|
||||
INFOMANIAK_TOKEN_URL = "https://login.infomaniak.com/token"
|
||||
INFOMANIAK_API_BASE = "https://api.infomaniak.com"
|
||||
|
||||
CLIENT_ID = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_ID")
|
||||
CLIENT_SECRET = APP_CONFIG.get("Service_INFOMANIAK_DATA_CLIENT_SECRET")
|
||||
REDIRECT_URI = APP_CONFIG.get("Service_INFOMANIAK_OAUTH_REDIRECT_URI")
|
||||
|
||||
|
||||
def _issue_oauth_state(claims: Dict[str, Any]) -> str:
|
||||
body = {**claims, "exp": int(time.time()) + 600}
|
||||
return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def _parse_oauth_state(state: str) -> Dict[str, Any]:
|
||||
try:
|
||||
return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}"
|
||||
) from e
|
||||
|
||||
|
||||
def _require_infomaniak_config():
|
||||
if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=routeApiMsg(
|
||||
"Infomaniak OAuth is not configured "
|
||||
"(Service_INFOMANIAK_DATA_CLIENT_ID, Service_INFOMANIAK_DATA_CLIENT_SECRET, "
|
||||
"Service_INFOMANIAK_OAUTH_REDIRECT_URI)"
|
||||
),
|
||||
)
|
||||
# Infomaniak PATs do not expire unless the user sets an explicit lifetime in
|
||||
# the Manager (up to 30 years). We persist a 10-year horizon so the central
|
||||
# tokenStatus helper does not flag the connection as "no token". Mirrors
|
||||
# ClickUp.
|
||||
_INFOMANIAK_TOKEN_EXPIRES_IN_SEC = 10 * 365 * 24 * 3600
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
|
|
@ -78,251 +81,192 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/auth/connect")
|
||||
@limiter.limit("5/minute")
|
||||
def auth_connect(
|
||||
request: Request,
|
||||
connectionId: str = Query(..., description="UserConnection id"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> RedirectResponse:
|
||||
"""Start Infomaniak OAuth for an existing connection (requires gateway session)."""
|
||||
async def _probeDriveScope(client: httpx.AsyncClient, pat: str, accountId: int) -> None:
|
||||
"""Confirm the ``drive`` scope is on the PAT.
|
||||
|
||||
Issues ``GET /2/drive?account_id={accountId}`` -- a clean 200 means
|
||||
both the ``drive`` scope is present and the resolved ``account_id``
|
||||
is correct. 401/403 means the scope is missing; anything else means
|
||||
Infomaniak is misbehaving and we refuse to persist.
|
||||
"""
|
||||
url = f"{INFOMANIAK_API_BASE}/2/drive?account_id={accountId}"
|
||||
try:
|
||||
_require_infomaniak_config()
|
||||
interface = getInterface(currentUser)
|
||||
connections = interface.getUserConnections(currentUser.id)
|
||||
connection = None
|
||||
for conn in connections:
|
||||
if conn.id == connectionId and conn.authority == AuthAuthority.INFOMANIAK:
|
||||
connection = conn
|
||||
break
|
||||
if not connection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=routeApiMsg("Infomaniak connection not found"),
|
||||
)
|
||||
|
||||
state_jwt = _issue_oauth_state(
|
||||
{
|
||||
"flow": _FLOW_CONNECT,
|
||||
"connectionId": connectionId,
|
||||
"userId": str(currentUser.id),
|
||||
}
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {pat}", "Accept": "application/json"},
|
||||
)
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"access_type": "offline",
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": " ".join(infomaniakDataScopes),
|
||||
"state": state_jwt,
|
||||
}
|
||||
)
|
||||
auth_url = f"{INFOMANIAK_AUTHORIZE_URL}?{query}"
|
||||
return RedirectResponse(auth_url)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error initiating Infomaniak connect: {str(e)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Infomaniak drive-probe network error ({url}): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to initiate Infomaniak connect: {str(e)}",
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=routeApiMsg("Could not reach Infomaniak to validate the token"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth/connect/callback")
|
||||
async def auth_connect_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(...),
|
||||
) -> HTMLResponse:
|
||||
"""OAuth callback for Infomaniak data connection."""
|
||||
state_data = _parse_oauth_state(state)
|
||||
if state_data.get("flow") != _FLOW_CONNECT:
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
if resp.status_code in (401, 403):
|
||||
logger.warning(
|
||||
f"Infomaniak drive-probe rejected PAT ({url}): "
|
||||
f"{resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=routeApiMsg(
|
||||
"Token rejected by Infomaniak (missing scope 'drive'). "
|
||||
"Required scopes: 'drive' (kDrive) and 'workspace:calendar' "
|
||||
"(or 'workspace:contact'). Recommended for upcoming "
|
||||
"services: 'workspace:mail'."
|
||||
),
|
||||
)
|
||||
connection_id = state_data.get("connectionId")
|
||||
user_id = state_data.get("userId")
|
||||
if not connection_id or not user_id:
|
||||
logger.error(
|
||||
f"Infomaniak drive-probe unexpected response ({url}): "
|
||||
f"{resp.status_code} {resp.text[:200]}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=routeApiMsg(
|
||||
"Infomaniak drive-probe returned an unexpected response."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connections/{connectionId}/token")
|
||||
@limiter.limit("10/minute")
|
||||
async def submit_infomaniak_token(
|
||||
request: Request,
|
||||
connectionId: str = Path(..., description="UserConnection id"),
|
||||
body: Dict[str, Any] = Body(..., description="{ 'token': '<PAT>' }"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate and persist an Infomaniak Personal Access Token (PAT).
|
||||
|
||||
Body:
|
||||
{ "token": "<personal-access-token from Infomaniak Manager>" }
|
||||
|
||||
Validation order (all three must succeed before persisting):
|
||||
1. ``resolveAccessibleAccountIds(pat)`` -> proves the
|
||||
``accounts`` scope is on the PAT (required for kDrive
|
||||
account discovery).
|
||||
2. ``resolveOwnerIdentity(pat)`` -> display name + kSuite
|
||||
account_id for the connection UI label.
|
||||
3. ``/2/drive?account_id=<first>`` -> proves the ``drive``
|
||||
scope is on the PAT.
|
||||
|
||||
No data derived from the PAT is stored as adapter state -- both
|
||||
account list and owner identity are re-resolved lazily by the
|
||||
adapters at request time.
|
||||
"""
|
||||
pat = (body or {}).get("token")
|
||||
if not isinstance(pat, str) or not pat.strip():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=routeApiMsg("Missing 'token' in request body"),
|
||||
)
|
||||
pat = pat.strip()
|
||||
|
||||
_require_infomaniak_config()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_resp = await client.post(
|
||||
INFOMANIAK_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
logger.error(
|
||||
f"Infomaniak token exchange failed: {token_resp.status_code} {token_resp.text}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=f"<html><body><h1>Connection Failed</h1><p>{token_resp.text}</p></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
token_json = token_resp.json()
|
||||
access_token = token_json.get("access_token")
|
||||
refresh_token = token_json.get("refresh_token", "")
|
||||
expires_in = int(token_json.get("expires_in", 0))
|
||||
granted_scopes = token_json.get("scope", "")
|
||||
|
||||
if not access_token:
|
||||
return HTMLResponse(
|
||||
content="<html><body><h1>Connection Failed</h1><p>No access token.</p></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
rootInterface = getRootInterface()
|
||||
if not refresh_token:
|
||||
try:
|
||||
existing_tokens = rootInterface.getTokensByConnectionIdAndAuthority(
|
||||
connection_id, AuthAuthority.INFOMANIAK
|
||||
)
|
||||
if existing_tokens:
|
||||
existing_tokens.sort(
|
||||
key=lambda x: parseTimestamp(x.createdAt, default=0), reverse=True
|
||||
)
|
||||
refresh_token = existing_tokens[0].tokenRefresh or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
profile_resp = await client.get(
|
||||
f"{INFOMANIAK_API_BASE}/1/profile",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
if profile_resp.status_code != 200:
|
||||
logger.error(
|
||||
f"Infomaniak profile lookup failed: {profile_resp.status_code} {profile_resp.text}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
content="<html><body><h1>Connection Failed</h1><p>Could not load Infomaniak profile.</p></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
profile_payload = profile_resp.json()
|
||||
profile = profile_payload.get("data") if isinstance(profile_payload, dict) else None
|
||||
profile = profile or {}
|
||||
|
||||
user = rootInterface.getUser(user_id)
|
||||
if not user:
|
||||
return HTMLResponse(
|
||||
content="""
|
||||
<html><body><script>
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'infomaniak_connection_error', error: 'User not found' }, '*');
|
||||
setTimeout(() => window.close(), 1000);
|
||||
} else window.close();
|
||||
</script></body></html>
|
||||
""",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
interface = getInterface(user)
|
||||
connections = interface.getUserConnections(user_id)
|
||||
interface = getInterface(currentUser)
|
||||
connection = None
|
||||
for conn in connections:
|
||||
if conn.id == connection_id:
|
||||
for conn in interface.getUserConnections(currentUser.id):
|
||||
if conn.id == connectionId and conn.authority == AuthAuthority.INFOMANIAK:
|
||||
connection = conn
|
||||
break
|
||||
if not connection:
|
||||
return HTMLResponse(
|
||||
content="""
|
||||
<html><body><script>
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'infomaniak_connection_error', error: 'Connection not found' }, '*');
|
||||
setTimeout(() => window.close(), 1000);
|
||||
} else window.close();
|
||||
</script></body></html>
|
||||
""",
|
||||
status_code=404,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=routeApiMsg("Infomaniak connection not found"),
|
||||
)
|
||||
|
||||
ext_id = str(profile.get("id", "")) if profile.get("id") is not None else ""
|
||||
username = profile.get("login") or profile.get("email") or ext_id
|
||||
email = profile.get("email")
|
||||
try:
|
||||
accountIds = await resolveAccessibleAccountIds(pat)
|
||||
except InfomaniakIdentityError as e:
|
||||
logger.warning(
|
||||
f"Infomaniak token submit for connection {connectionId} could not "
|
||||
f"list accounts: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=routeApiMsg(
|
||||
"Token rejected by Infomaniak (missing scope 'accounts'). "
|
||||
"kDrive needs the 'accounts' scope to discover the owning "
|
||||
"Infomaniak account. Required scopes: 'accounts', 'drive', "
|
||||
"'workspace:calendar', 'workspace:contact'."
|
||||
),
|
||||
)
|
||||
|
||||
expires_at = createExpirationTimestamp(expires_in)
|
||||
granted_scopes_list = (
|
||||
granted_scopes
|
||||
if isinstance(granted_scopes, list)
|
||||
else (granted_scopes.split(" ") if granted_scopes else infomaniakDataScopes)
|
||||
)
|
||||
try:
|
||||
identity = await resolveOwnerIdentity(pat)
|
||||
except InfomaniakIdentityError as e:
|
||||
logger.warning(
|
||||
f"Infomaniak token submit for connection {connectionId} could not "
|
||||
f"resolve owner identity: {e}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=routeApiMsg(
|
||||
"Could not derive your Infomaniak account from the token. "
|
||||
"Please ensure the PAT carries 'workspace:calendar' or "
|
||||
"'workspace:contact' so we can identify your account."
|
||||
),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as client:
|
||||
await _probeDriveScope(client, pat, accountIds[0])
|
||||
|
||||
tokenFingerprint = "pat-" + hashlib.sha256(pat.encode("utf-8")).hexdigest()[:8]
|
||||
username = identity["displayName"] or f"infomaniak-{tokenFingerprint}"
|
||||
expiresAt = createExpirationTimestamp(_INFOMANIAK_TOKEN_EXPIRES_IN_SEC)
|
||||
|
||||
try:
|
||||
connection.status = ConnectionStatus.ACTIVE
|
||||
connection.lastChecked = getUtcTimestamp()
|
||||
connection.expiresAt = expires_at
|
||||
connection.externalId = ext_id
|
||||
connection.expiresAt = expiresAt
|
||||
connection.externalId = str(identity["accountId"])
|
||||
connection.externalUsername = username
|
||||
if email:
|
||||
connection.externalEmail = email
|
||||
connection.grantedScopes = granted_scopes_list
|
||||
rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump())
|
||||
connection.grantedScopes = [
|
||||
"accounts",
|
||||
"drive",
|
||||
"workspace:mail",
|
||||
"workspace:calendar",
|
||||
"workspace:contact",
|
||||
]
|
||||
interface.db.recordModify(UserConnection, connectionId, connection.model_dump())
|
||||
|
||||
token = Token(
|
||||
userId=user.id,
|
||||
userId=currentUser.id,
|
||||
authority=AuthAuthority.INFOMANIAK,
|
||||
connectionId=connection_id,
|
||||
connectionId=connectionId,
|
||||
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||
tokenAccess=access_token,
|
||||
tokenRefresh=refresh_token,
|
||||
tokenType=token_json.get("token_type", "bearer"),
|
||||
expiresAt=expires_at,
|
||||
tokenAccess=pat,
|
||||
tokenRefresh=None,
|
||||
tokenType="bearer",
|
||||
expiresAt=expiresAt,
|
||||
createdAt=getUtcTimestamp(),
|
||||
)
|
||||
interface.saveConnectionToken(token)
|
||||
|
||||
return HTMLResponse(
|
||||
content=f"""
|
||||
<html>
|
||||
<head><title>Connection Successful</title></head>
|
||||
<body>
|
||||
<script>
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{
|
||||
type: 'infomaniak_connection_success',
|
||||
connection: {{
|
||||
id: '{connection.id}',
|
||||
status: 'connected',
|
||||
type: 'infomaniak',
|
||||
lastChecked: {getUtcTimestamp()},
|
||||
expiresAt: {expires_at}
|
||||
}}
|
||||
}}, '*');
|
||||
setTimeout(() => window.close(), 1000);
|
||||
}} else {{
|
||||
window.close();
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
logger.info(
|
||||
f"Infomaniak PAT stored for connection {connectionId} "
|
||||
f"(user {currentUser.id}, externalUsername={username}, "
|
||||
f"kSuiteAccountId={identity['accountId']}, "
|
||||
f"accessibleAccounts={accountIds})"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": connection.id,
|
||||
"status": "connected",
|
||||
"type": "infomaniak",
|
||||
"externalUsername": username,
|
||||
"externalEmail": None,
|
||||
"lastChecked": connection.lastChecked,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating Infomaniak connection: {str(e)}", exc_info=True)
|
||||
return HTMLResponse(
|
||||
content=f"""
|
||||
<html><body><script>
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{ type: 'infomaniak_connection_error', error: {json.dumps(str(e))} }}, '*');
|
||||
setTimeout(() => window.close(), 1000);
|
||||
}} else window.close();
|
||||
</script></body></html>
|
||||
""",
|
||||
status_code=500,
|
||||
logger.error(
|
||||
f"Error persisting Infomaniak token for connection {connectionId}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=routeApiMsg("Failed to store Infomaniak token"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -244,9 +244,15 @@ async def auth_login_callback(
|
|||
def auth_connect(
|
||||
request: Request,
|
||||
connectionId: str = Query(..., description="UserConnection id"),
|
||||
reauth: Optional[int] = Query(0, description="If 1, force the consent screen so newly added scopes are granted"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> RedirectResponse:
|
||||
"""Start Microsoft Data OAuth for an existing connection."""
|
||||
"""Start Microsoft Data OAuth for an existing connection.
|
||||
|
||||
With ``reauth=1`` the consent screen is forced (``prompt=consent``) so the
|
||||
user re-grants permissions and any newly added scopes (e.g. Calendars.Read,
|
||||
Contacts.Read) actually land on the access token.
|
||||
"""
|
||||
try:
|
||||
_require_msft_data_config()
|
||||
interface = getInterface(currentUser)
|
||||
|
|
@ -280,6 +286,8 @@ def auth_connect(
|
|||
if "@" in login_hint:
|
||||
login_kwargs["domain_hint"] = login_hint.split("@", 1)[1]
|
||||
login_kwargs["prompt"] = "login"
|
||||
if reauth:
|
||||
login_kwargs["prompt"] = "consent"
|
||||
|
||||
auth_url = msal_app.get_authorization_request_url(
|
||||
scopes=msftDataScopes,
|
||||
|
|
|
|||
0
tests/unit/aicore/__init__.py
Normal file
0
tests/unit/aicore/__init__.py
Normal file
66
tests/unit/aicore/test_aicorePluginOpenai_temperature.py
Normal file
66
tests/unit/aicore/test_aicorePluginOpenai_temperature.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Copyright (c) 2026 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Unit tests: temperature handling for OpenAI chat-completions models.
|
||||
|
||||
Historical regression: every payload sent ``temperature=0.2``. After the
|
||||
GPT-5 launch OpenAI rejects any non-default temperature for the GPT-5.x
|
||||
and o-series (o1/o3/o4) reasoning models with HTTP 400::
|
||||
|
||||
"Unsupported value: 'temperature' does not support 0.2 with this
|
||||
model. Only the default (1) value is supported."
|
||||
|
||||
The fix is a single helper, ``_supportsCustomTemperature``, that is
|
||||
consulted before adding the field to the outgoing payload. These tests
|
||||
pin the contract:
|
||||
|
||||
* legacy chat models (gpt-4o, gpt-4o-mini, gpt-4.1, gpt-3.5-*) keep
|
||||
honoring custom temperatures,
|
||||
* every gpt-5.x and o1/o3/o4 variant must omit the field entirely.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.aicore.aicorePluginOpenai import _supportsCustomTemperature
|
||||
|
||||
|
||||
class TestSupportsCustomTemperature:
|
||||
"""Pure model-name classification - no network, no payload assembly."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"modelName",
|
||||
[
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-3.5-turbo",
|
||||
"text-embedding-3-small",
|
||||
"dall-e-3",
|
||||
],
|
||||
)
|
||||
def testLegacyModelsAcceptCustomTemperature(self, modelName):
|
||||
assert _supportsCustomTemperature(modelName) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"modelName",
|
||||
[
|
||||
"gpt-5",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5.5",
|
||||
"GPT-5.5",
|
||||
"o1",
|
||||
"o1-mini",
|
||||
"o3",
|
||||
"o3-mini",
|
||||
"o4-mini",
|
||||
],
|
||||
)
|
||||
def testReasoningModelsRejectCustomTemperature(self, modelName):
|
||||
assert _supportsCustomTemperature(modelName) is False
|
||||
|
||||
def testEmptyOrNoneModelDefaultsToSupported(self):
|
||||
# Defensive: unknown/empty names should not silently break legacy paths.
|
||||
assert _supportsCustomTemperature("") is True
|
||||
assert _supportsCustomTemperature(None) is True
|
||||
Loading…
Reference in a new issue