diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b64c7afba..0b52e597e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,7 +125,7 @@ ### Add supported versions Supported versions are hardcoded into Coolify (for now). -You need to update `supportedServiceTypesAndVersions` function at [src/apps/api/src/lib/supportedVersions.ts](src/apps/api/src/lib/supportedVersions.ts). Example JSON: +You need to update `supportedServiceTypesAndVersions` function at [apps/api/src/lib/services/supportedVersions.ts](apps/api/src/lib/services/supportedVersions.ts). Example JSON: ```js { @@ -209,21 +209,22 @@ ### Add required functions/properties 4. Add service deletion query to `removeService` function in [apps/api/src/lib/services/common.ts](apps/api/src/lib/services/common.ts) -5. You need to add start process for the new service in [apps/api/src/routes/api/v1/services/handlers.ts](apps/api/src/routes/api/v1/services/handlers.ts) +5. Add start process for the new service in [apps/api/src/routes/api/v1/services/handlers.ts](apps/api/src/routes/api/v1/services/handlers.ts) > See startUmamiService() function as example. +6. Add the newly added start process to `startService` in [apps/api/src/routes/api/v1/services/handlers.ts](apps/api/src/routes/api/v1/services/handlers.ts) -6. You need to add a custom logo at [apps/ui/src/lib/components/svg/services](apps/ui/src/lib/components/svg/services) as a svelte component and export it in [apps/ui/src/lib/components/svg/services/index.ts](apps/ui/src/lib/components/svg/services/index.ts) +7. You need to add a custom logo at [apps/ui/src/lib/components/svg/services](apps/ui/src/lib/components/svg/services) as a svelte component and export it in [apps/ui/src/lib/components/svg/services/index.ts](apps/ui/src/lib/components/svg/services/index.ts) SVG is recommended, but you can use PNG as well. It should have the `isAbsolute` variable with the suitable CSS classes, primarily for sizing and positioning. -7. You need to include it the logo at: +8. You need to include it the logo at: - [apps/ui/src/lib/components/svg/services/ServiceIcons.svelte](apps/ui/src/lib/components/svg/services/ServiceIcons.svelte) with `isAbsolute`. - [apps/ui/src/routes/services/[id]/_ServiceLinks.svelte](apps/ui/src/routes/services/[id]/_ServiceLinks.svelte) with the link to the docs/main site of the service -8. By default the URL and the name frontend forms are included in [apps/ui/src/routes/services/[id]/_Services/_Services.svelte](apps/ui/src/routes/services/[id]/_Services/_Services.svelte). +9. By default the URL and the name frontend forms are included in [apps/ui/src/routes/services/[id]/_Services/_Services.svelte](apps/ui/src/routes/services/[id]/_Services/_Services.svelte). If you need to show more details on the frontend, such as users/passwords, you need to add Svelte component to [apps/ui/src/routes/services/[id]/_Services](apps/ui/src/routes/services/[id]/_Services) with an underscore. diff --git a/apps/api/prisma/migrations/20220831095714_service_weblate/migration.sql b/apps/api/prisma/migrations/20220831095714_service_weblate/migration.sql new file mode 100644 index 000000000..c985b4ae2 --- /dev/null +++ b/apps/api/prisma/migrations/20220831095714_service_weblate/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "Weblate" ( + "id" TEXT NOT NULL PRIMARY KEY, + "adminPassword" TEXT NOT NULL, + "postgresqlHost" TEXT NOT NULL, + "postgresqlPort" INTEGER NOT NULL, + "postgresqlUser" TEXT NOT NULL, + "postgresqlPassword" TEXT NOT NULL, + "postgresqlDatabase" TEXT NOT NULL, + "postgresqlPublicPort" INTEGER, + "serviceId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Weblate_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Weblate_serviceId_key" ON "Weblate"("serviceId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index dfa7ae26b..dc254b6df 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -200,7 +200,7 @@ model Build { commit String? pullmergeRequestId String? forceRebuild Boolean @default(false) - sourceBranch String? + sourceBranch String? branch String? status String? @default("queued") createdAt DateTime @default(now()) @@ -348,6 +348,7 @@ model Service { wordpress Wordpress? appwrite Appwrite? searxng Searxng? + weblate Weblate? } model PlausibleAnalytics { @@ -559,3 +560,18 @@ model Searxng { updatedAt DateTime @updatedAt service Service @relation(fields: [serviceId], references: [id]) } + +model Weblate { + id String @id @default(cuid()) + adminPassword String + postgresqlHost String + postgresqlPort Int + postgresqlUser String + postgresqlPassword String + postgresqlDatabase String + postgresqlPublicPort Int? + serviceId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + service Service @relation(fields: [serviceId], references: [id]) +} diff --git a/apps/api/src/lib/common.ts b/apps/api/src/lib/common.ts index f38dfef82..149ee0820 100644 --- a/apps/api/src/lib/common.ts +++ b/apps/api/src/lib/common.ts @@ -21,7 +21,7 @@ import { scheduler } from './scheduler'; import { supportedServiceTypesAndVersions } from './services/supportedVersions'; import { includeServices } from './services/common'; -export const version = '3.8.9'; +export const version = '3.9.0'; export const isDev = process.env.NODE_ENV === 'development'; const algorithm = 'aes-256-ctr'; @@ -45,7 +45,7 @@ export function getAPIUrl() { if (process.env.CODESANDBOX_HOST) { return `https://${process.env.CODESANDBOX_HOST.replace(/\$PORT/, '3001')}` } - return isDev ? 'http://localhost:3001' : 'http://localhost:3000'; + return isDev ? 'http://host.docker.internal:3001' : 'http://localhost:3000'; } export function getUIUrl() { @@ -1309,6 +1309,9 @@ export function saveUpdateableFields(type: string, data: any) { temp = Boolean(temp) } } + if (k.isNumber && temp === '') { + temp = null + } update[k.name] = temp }); } diff --git a/apps/api/src/lib/services/common.ts b/apps/api/src/lib/services/common.ts index 44caeabe9..ff63bc9e3 100644 --- a/apps/api/src/lib/services/common.ts +++ b/apps/api/src/lib/services/common.ts @@ -1,23 +1,7 @@ -import { exec } from 'node:child_process' -import util from 'util'; -import fs from 'fs/promises'; -import yaml from 'js-yaml'; -import forge from 'node-forge'; -import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator'; -import type { Config } from 'unique-names-generator'; -import generator from 'generate-password'; -import crypto from 'crypto'; -import { promises as dns } from 'dns'; -import { PrismaClient } from '@prisma/client'; + import cuid from 'cuid'; -import os from 'os'; -import sshConfig from 'ssh-config' import { encrypt, generatePassword, prisma } from '../common'; - -export const version = '3.8.2'; -export const isDev = process.env.NODE_ENV === 'development'; - export const includeServices: any = { destinationDocker: true, persistentStorage: true, @@ -34,7 +18,8 @@ export const includeServices: any = { moodle: true, appwrite: true, glitchTip: true, - searxng: true + searxng: true, + weblate: true }; export async function configureServiceType({ id, @@ -312,6 +297,27 @@ export async function configureServiceType({ } } }); + }else if (type === 'weblate') { + const adminPassword = encrypt(generatePassword({})) + const postgresqlUser = cuid(); + const postgresqlPassword = encrypt(generatePassword({})); + const postgresqlDatabase = 'weblate'; + await prisma.service.update({ + where: { id }, + data: { + type, + weblate: { + create: { + adminPassword, + postgresqlHost: `${id}-postgresql`, + postgresqlPort: 5432, + postgresqlUser, + postgresqlPassword, + postgresqlDatabase, + } + } + } + }); } else { await prisma.service.update({ where: { id }, @@ -338,7 +344,7 @@ export async function removeService({ id }: { id: string }): Promise { await prisma.moodle.deleteMany({ where: { serviceId: id } }); await prisma.appwrite.deleteMany({ where: { serviceId: id } }); await prisma.searxng.deleteMany({ where: { serviceId: id } }); - + await prisma.weblate.deleteMany({ where: { serviceId: id } }); await prisma.service.delete({ where: { id } }); } \ No newline at end of file diff --git a/apps/api/src/lib/services/handlers.ts b/apps/api/src/lib/services/handlers.ts index cbefd7a94..f175f77d0 100644 --- a/apps/api/src/lib/services/handlers.ts +++ b/apps/api/src/lib/services/handlers.ts @@ -63,6 +63,9 @@ export async function startService(request: FastifyRequest) { if (type === 'searxng') { return await startSearXNGService(request) } + if (type === 'weblate') { + return await startWeblateService(request) + } throw `Service type ${type} not supported.` } catch (error) { throw { status: 500, message: error?.message || error } @@ -2224,3 +2227,106 @@ async function startSearXNGService(request: FastifyRequest) { return errorHandler({ status, message }) } } + + +async function startWeblateService(request: FastifyRequest) { + try { + const { id } = request.params; + const teamId = request.user.teamId; + const service = await getServiceFromDB({ id, teamId }); + const { + weblate: { adminPassword, postgresqlHost, postgresqlPort, postgresqlUser, postgresqlPassword, postgresqlDatabase } + } = service; + const { type, version, destinationDockerId, destinationDocker, serviceSecret, exposePort, persistentStorage, fqdn } = + service; + const network = destinationDockerId && destinationDocker.network; + const port = getServiceMainPort('weblate'); + + const { workdir } = await createDirectories({ repository: type, buildId: id }); + const image = getServiceImage(type); + + const config = { + weblate: { + image: `${image}:${version}`, + volume: `${id}-data:/app/data`, + environmentVariables: { + WEBLATE_SITE_DOMAIN: getDomain(fqdn), + WEBLATE_ADMIN_PASSWORD: adminPassword, + POSTGRES_PASSWORD: postgresqlPassword, + POSTGRES_USER: postgresqlUser, + POSTGRES_DATABASE: postgresqlDatabase, + POSTGRES_HOST: postgresqlHost, + POSTGRES_PORT: postgresqlPort, + REDIS_HOST: `${id}-redis`, + } + }, + postgresql: { + image: `postgres:14-alpine`, + volume: `${id}-postgresql-data:/var/lib/postgresql/data`, + environmentVariables: { + POSTGRES_PASSWORD: postgresqlPassword, + POSTGRES_USER: postgresqlUser, + POSTGRES_DB: postgresqlDatabase, + POSTGRES_HOST: postgresqlHost, + POSTGRES_PORT: postgresqlPort, + } + }, + redis: { + image: `redis:6-alpine`, + volume: `${id}-redis-data:/data`, + } + + }; + + if (serviceSecret.length > 0) { + serviceSecret.forEach((secret) => { + config.weblate.environmentVariables[secret.name] = secret.value; + }); + } + const { volumes, volumeMounts } = persistentVolumes(id, persistentStorage, config) + const composeFile: ComposeFile = { + version: '3.8', + services: { + [id]: { + container_name: id, + image: config.weblate.image, + environment: config.weblate.environmentVariables, + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + volumes, + labels: makeLabelForServices('weblate'), + ...defaultComposeConfiguration(network), + }, + [`${id}-postgresql`]: { + container_name: `${id}-postgresql`, + image: config.postgresql.image, + environment: config.postgresql.environmentVariables, + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + volumes, + labels: makeLabelForServices('weblate'), + ...defaultComposeConfiguration(network), + }, + [`${id}-redis`]: { + container_name: `${id}-redis`, + image: config.redis.image, + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + volumes, + labels: makeLabelForServices('weblate'), + ...defaultComposeConfiguration(network), + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: volumeMounts + }; + const composeFileDestination = `${workdir}/docker-compose.yaml`; + await fs.writeFile(composeFileDestination, yaml.dump(composeFile)); + await startServiceContainers(destinationDocker.id, composeFileDestination) + return {} + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} + diff --git a/apps/api/src/lib/services/serviceFields.ts b/apps/api/src/lib/services/serviceFields.ts index a9d5b379a..7994643cf 100644 --- a/apps/api/src/lib/services/serviceFields.ts +++ b/apps/api/src/lib/services/serviceFields.ts @@ -599,6 +599,54 @@ export const glitchTip = [{ isBoolean: false, isEncrypted: true }, +{ + name: 'emailSmtpHost', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpPassword', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'emailSmtpUseSsl', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: true, + isEncrypted: false +}, +{ + name: 'emailSmtpUseSsl', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: true, + isEncrypted: false +}, +{ + name: 'emailSmtpPort', + isEditable: true, + isLowerCase: false, + isNumber: true, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'emailSmtpUser', + isEditable: true, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, { name: 'defaultEmail', isEditable: false, @@ -624,7 +672,7 @@ export const glitchTip = [{ isEncrypted: true }, { - name: 'defaultFromEmail', + name: 'defaultEmailFrom', isEditable: true, isLowerCase: false, isNumber: false, @@ -687,4 +735,53 @@ export const searxng = [{ isNumber: false, isBoolean: false, isEncrypted: true +}] + +export const weblate = [{ + name: 'adminPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlHost', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPort', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlUser', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false +}, +{ + name: 'postgresqlPassword', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: true +}, +{ + name: 'postgresqlDatabase', + isEditable: false, + isLowerCase: false, + isNumber: false, + isBoolean: false, + isEncrypted: false }] \ No newline at end of file diff --git a/apps/api/src/lib/services/supportedVersions.ts b/apps/api/src/lib/services/supportedVersions.ts index b6f039695..c31ef1993 100644 --- a/apps/api/src/lib/services/supportedVersions.ts +++ b/apps/api/src/lib/services/supportedVersions.ts @@ -190,4 +190,15 @@ export const supportedServiceTypesAndVersions = [ main: 8080 } }, + { + name: 'weblate', + fancyName: 'Weblate', + baseImage: 'weblate/weblate', + images: ['postgres:14-alpine','redis:6-alpine'], + versions: ['latest'], + recommendedVersion: 'latest', + ports: { + main: 8080 + } + }, ]; \ No newline at end of file diff --git a/apps/api/src/routes/api/v1/applications/handlers.ts b/apps/api/src/routes/api/v1/applications/handlers.ts index c83504f94..27fbdee7b 100644 --- a/apps/api/src/routes/api/v1/applications/handlers.ts +++ b/apps/api/src/routes/api/v1/applications/handlers.ts @@ -3,16 +3,23 @@ import crypto from 'node:crypto' import jsonwebtoken from 'jsonwebtoken'; import axios from 'axios'; import { FastifyReply } from 'fastify'; +import fs from 'fs/promises'; +import yaml from 'js-yaml'; + import { day } from '../../../../lib/dayjs'; -import { setDefaultBaseImage, setDefaultConfiguration } from '../../../../lib/buildPacks/common'; -import { checkDomainsIsValidInDNS, checkDoubleBranch, checkExposedPort, decrypt, encrypt, errorHandler, executeDockerCmd, generateSshKeyPair, getContainerUsage, getDomain, getFreeExposedPort, isDev, isDomainConfigured, listSettings, prisma, stopBuild, uniqueName } from '../../../../lib/common'; +import { makeLabelForStandaloneApplication, setDefaultBaseImage, setDefaultConfiguration } from '../../../../lib/buildPacks/common'; +import { checkDomainsIsValidInDNS, checkDoubleBranch, checkExposedPort, createDirectories, decrypt, defaultComposeConfiguration, encrypt, errorHandler, executeDockerCmd, generateSshKeyPair, getContainerUsage, getDomain, isDev, isDomainConfigured, listSettings, prisma, stopBuild, uniqueName } from '../../../../lib/common'; import { checkContainer, formatLabelsOnDocker, isContainerExited, removeContainer } from '../../../../lib/docker'; -import { scheduler } from '../../../../lib/scheduler'; import type { FastifyRequest } from 'fastify'; import type { GetImages, CancelDeployment, CheckDNS, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, DeployApplication, CheckDomain, StopPreviewApplication } from './types'; import { OnlyId } from '../../../../types'; +function filterObject(obj, callback) { + return Object.fromEntries(Object.entries(obj). + filter(([key, val]) => callback(val, key))); +} + export async function listApplications(request: FastifyRequest) { try { const { teamId } = request.user @@ -312,6 +319,113 @@ export async function stopPreviewApplication(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { teamId } = request.user + let application: any = await getApplicationFromDB(id, teamId); + if (application?.destinationDockerId) { + const buildId = cuid(); + const { id: dockerId, network } = application.destinationDocker; + const { secrets, pullmergeRequestId, port, repository, persistentStorage, id: applicationId, buildPack, exposePort } = application; + + const envs = [ + `PORT=${port}` + ]; + if (secrets.length > 0) { + secrets.forEach((secret) => { + if (pullmergeRequestId) { + if (secret.isPRMRSecret) { + envs.push(`${secret.name}=${secret.value}`); + } + } else { + if (!secret.isPRMRSecret) { + envs.push(`${secret.name}=${secret.value}`); + } + } + }); + } + const { workdir } = await createDirectories({ repository, buildId }); + const labels = [] + let image = null + const { stdout: container } = await executeDockerCmd({ dockerId, command: `docker container ls --filter 'label=com.docker.compose.service=${id}' --format '{{json .}}'` }) + const containersArray = container.trim().split('\n'); + for (const container of containersArray) { + const containerObj = formatLabelsOnDocker(container); + image = containerObj[0].Image + Object.keys(containerObj[0].Labels).forEach(function (key) { + if (key.startsWith('coolify')) { + labels.push(`${key}=${containerObj[0].Labels[key]}`) + } + }) + } + let imageFound = false; + try { + await executeDockerCmd({ + dockerId, + command: `docker image inspect ${image}` + }) + imageFound = true; + } catch (error) { + // + } + if (!imageFound) { + throw { status: 500, message: 'Image not found, cannot restart application.' } + } + await fs.writeFile(`${workdir}/.env`, envs.join('\n')); + + let envFound = false; + try { + envFound = !!(await fs.stat(`${workdir}/.env`)); + } catch (error) { + // + } + const volumes = + persistentStorage?.map((storage) => { + return `${applicationId}${storage.path.replace(/\//gi, '-')}:${buildPack !== 'docker' ? '/app' : '' + }${storage.path}`; + }) || []; + const composeVolumes = volumes.map((volume) => { + return { + [`${volume.split(':')[0]}`]: { + name: volume.split(':')[0] + } + }; + }); + const composeFile = { + version: '3.8', + services: { + [applicationId]: { + image, + container_name: applicationId, + volumes, + env_file: envFound ? [`${workdir}/.env`] : [], + labels, + depends_on: [], + expose: [port], + ...(exposePort ? { ports: [`${exposePort}:${port}`] } : {}), + ...defaultComposeConfiguration(network), + } + }, + networks: { + [network]: { + external: true + } + }, + volumes: Object.assign({}, ...composeVolumes) + }; + await fs.writeFile(`${workdir}/docker-compose.yml`, yaml.dump(composeFile)); + await executeDockerCmd({ dockerId, command: `docker stop -t 0 ${id}` }) + await executeDockerCmd({ dockerId, command: `docker rm ${id}` }) + await executeDockerCmd({ dockerId, command: `docker compose --project-directory ${workdir} up -d` }) + return reply.code(201).send(); + } + throw { status: 500, message: 'Application cannot be restarted.' } + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} export async function stopApplication(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.params diff --git a/apps/api/src/routes/api/v1/applications/index.ts b/apps/api/src/routes/api/v1/applications/index.ts index 2f698ddeb..32ac1f98b 100644 --- a/apps/api/src/routes/api/v1/applications/index.ts +++ b/apps/api/src/routes/api/v1/applications/index.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from 'fastify'; import { OnlyId } from '../../../../types'; -import { cancelDeployment, checkDNS, checkDomain, checkRepository, deleteApplication, deleteSecret, deleteStorage, deployApplication, getApplication, getApplicationLogs, getApplicationStatus, getBuildIdLogs, getBuildLogs, getBuildPack, getGitHubToken, getGitLabSSHKey, getImages, getPreviews, getSecrets, getStorages, getUsage, listApplications, newApplication, saveApplication, saveApplicationSettings, saveApplicationSource, saveBuildPack, saveDeployKey, saveDestination, saveGitLabSSHKey, saveRepository, saveSecret, saveStorage, stopApplication, stopPreviewApplication } from './handlers'; +import { cancelDeployment, checkDNS, checkDomain, checkRepository, deleteApplication, deleteSecret, deleteStorage, deployApplication, getApplication, getApplicationLogs, getApplicationStatus, getBuildIdLogs, getBuildLogs, getBuildPack, getGitHubToken, getGitLabSSHKey, getImages, getPreviews, getSecrets, getStorages, getUsage, listApplications, newApplication, restartApplication, saveApplication, saveApplicationSettings, saveApplicationSource, saveBuildPack, saveDeployKey, saveDestination, saveGitLabSSHKey, saveRepository, saveSecret, saveStorage, stopApplication, stopPreviewApplication } from './handlers'; import type { CancelDeployment, CheckDNS, CheckDomain, CheckRepository, DeleteApplication, DeleteSecret, DeleteStorage, DeployApplication, GetApplicationLogs, GetBuildIdLogs, GetBuildLogs, GetImages, SaveApplication, SaveApplicationSettings, SaveApplicationSource, SaveDeployKey, SaveDestination, SaveSecret, SaveStorage, StopPreviewApplication } from './types'; @@ -19,6 +19,7 @@ const root: FastifyPluginAsync = async (fastify): Promise => { fastify.get('/:id/status', async (request) => await getApplicationStatus(request)); + fastify.post('/:id/restart', async (request, reply) => await restartApplication(request, reply)); fastify.post('/:id/stop', async (request, reply) => await stopApplication(request, reply)); fastify.post('/:id/stop/preview', async (request, reply) => await stopPreviewApplication(request, reply)); diff --git a/apps/api/src/routes/api/v1/services/handlers.ts b/apps/api/src/routes/api/v1/services/handlers.ts index 8691f2b41..53d446a48 100644 --- a/apps/api/src/routes/api/v1/services/handlers.ts +++ b/apps/api/src/routes/api/v1/services/handlers.ts @@ -2,13 +2,13 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import fs from 'fs/promises'; import yaml from 'js-yaml'; import bcrypt from 'bcryptjs'; -import { prisma, uniqueName, asyncExecShell, getServiceImage, getServiceFromDB, getContainerUsage,isDomainConfigured, saveUpdateableFields, fixType, decrypt, encrypt, getServiceMainPort, createDirectories, ComposeFile, makeLabelForServices, getFreePublicPort, getDomain, errorHandler, generatePassword, isDev, stopTcpHttpProxy, executeDockerCmd, checkDomainsIsValidInDNS, persistentVolumes, asyncSleep, isARM, defaultComposeConfiguration, checkExposedPort } from '../../../../lib/common'; +import { prisma, uniqueName, asyncExecShell, getServiceImage, getServiceFromDB, getContainerUsage, isDomainConfigured, saveUpdateableFields, fixType, decrypt, encrypt, getServiceMainPort, createDirectories, ComposeFile, makeLabelForServices, getFreePublicPort, getDomain, errorHandler, generatePassword, isDev, stopTcpHttpProxy, executeDockerCmd, checkDomainsIsValidInDNS, persistentVolumes, asyncSleep, isARM, defaultComposeConfiguration, checkExposedPort } from '../../../../lib/common'; import { day } from '../../../../lib/dayjs'; import { checkContainer, isContainerExited, removeContainer } from '../../../../lib/docker'; import cuid from 'cuid'; import type { OnlyId } from '../../../../types'; -import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types'; +import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetGlitchTipSettings, SetWordpressSettings } from './types'; import { defaultServiceConfigurations } from '../../../../lib/services'; import { supportedServiceTypesAndVersions } from '../../../../lib/services/supportedVersions'; import { configureServiceType, removeService } from '../../../../lib/services/common'; @@ -269,7 +269,6 @@ export async function saveService(request: FastifyRequest, reply: F if (exposePort) exposePort = Number(exposePort); type = fixType(type) - const update = saveUpdateableFields(type, request.body[type]) const data = { fqdn, @@ -400,17 +399,33 @@ export async function deleteServiceStorage(request: FastifyRequest, reply: FastifyReply) { +export async function setSettingsService(request: FastifyRequest, reply: FastifyReply) { try { const { type } = request.params if (type === 'wordpress') { return await setWordpressSettings(request, reply) } + if (type === 'glitchtip') { + return await setGlitchTipSettings(request, reply) + } throw `Service type ${type} not supported.` } catch ({ status, message }) { return errorHandler({ status, message }) } } +async function setGlitchTipSettings(request: FastifyRequest, reply: FastifyReply) { + try { + const { id } = request.params + const { enableOpenUserRegistration, emailSmtpUseSsl, emailSmtpUseTls } = request.body + await prisma.glitchTip.update({ + where: { serviceId: id }, + data: { enableOpenUserRegistration, emailSmtpUseSsl, emailSmtpUseTls } + }); + return reply.code(201).send() + } catch ({ status, message }) { + return errorHandler({ status, message }) + } +} async function setWordpressSettings(request: FastifyRequest, reply: FastifyReply) { try { const { id } = request.params diff --git a/apps/api/src/routes/api/v1/services/index.ts b/apps/api/src/routes/api/v1/services/index.ts index fd1ab061b..454f4c06e 100644 --- a/apps/api/src/routes/api/v1/services/index.ts +++ b/apps/api/src/routes/api/v1/services/index.ts @@ -29,7 +29,7 @@ import { } from './handlers'; import type { OnlyId } from '../../../../types'; -import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types'; +import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetGlitchTipSettings, SetWordpressSettings } from './types'; import { startService, stopService } from '../../../../lib/services/handlers'; const root: FastifyPluginAsync = async (fastify): Promise => { @@ -71,7 +71,7 @@ const root: FastifyPluginAsync = async (fastify): Promise => { fastify.post('/:id/:type/start', async (request) => await startService(request)); fastify.post('/:id/:type/stop', async (request) => await stopService(request)); - fastify.post('/:id/:type/settings', async (request, reply) => await setSettingsService(request, reply)); + fastify.post('/:id/:type/settings', async (request, reply) => await setSettingsService(request, reply)); fastify.post('/:id/plausibleanalytics/activate', async (request, reply) => await activatePlausibleUsers(request, reply)); fastify.post('/:id/plausibleanalytics/cleanup', async (request, reply) => await cleanupPlausibleLogs(request, reply)); diff --git a/apps/api/src/routes/api/v1/services/types.ts b/apps/api/src/routes/api/v1/services/types.ts index f09b4423f..098e7880f 100644 --- a/apps/api/src/routes/api/v1/services/types.ts +++ b/apps/api/src/routes/api/v1/services/types.ts @@ -89,6 +89,12 @@ export interface ActivateWordpressFtp extends OnlyId { } } - +export interface SetGlitchTipSettings extends OnlyId { + Body: { + enableOpenUserRegistration: boolean, + emailSmtpUseSsl: boolean, + emailSmtpUseTls: boolean + } +} diff --git a/apps/i18n/.env.example b/apps/i18n/.env.example new file mode 100644 index 000000000..8bdf45a64 --- /dev/null +++ b/apps/i18n/.env.example @@ -0,0 +1,4 @@ +WEBLATE_INSTANCE_URL=http://localhost +WEBLATE_COMPONENT_NAME=coolify +WEBLATE_TOKEN= +TRANSLATION_DIR= \ No newline at end of file diff --git a/apps/i18n/.gitignore b/apps/i18n/.gitignore new file mode 100644 index 000000000..df67586b0 --- /dev/null +++ b/apps/i18n/.gitignore @@ -0,0 +1 @@ +locales/* \ No newline at end of file diff --git a/apps/i18n/index.mjs b/apps/i18n/index.mjs new file mode 100644 index 000000000..85e146073 --- /dev/null +++ b/apps/i18n/index.mjs @@ -0,0 +1,63 @@ +import dotenv from 'dotenv'; +dotenv.config() +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url'; +import Gettext from 'node-gettext' +import { po } from 'gettext-parser' +import got from 'got'; +const __filename = fileURLToPath(import.meta.url); + +const __dirname = path.dirname(__filename); + +const weblateInstanceURL = process.env.WEBLATE_INSTANCE_URL; +const weblateComponentName = process.env.WEBLATE_COMPONENT_NAME +const token = process.env.WEBLATE_TOKEN; + +const translationsDir = process.env.TRANSLATION_DIR; +const translationsPODir = './locales'; +const locales = [] +const domain = 'locale' + +const translations = await got(`${weblateInstanceURL}/api/components/${weblateComponentName}/glossary/translations/?format=json`, { + headers: { + "Authorization": `Token ${token}` + } +}).json() +for (const translation of translations.results) { + const code = translation.language_code + locales.push(code) + + const fileUrl = translation.file_url.replace('=json', '=po') + const file = await got(fileUrl, { + headers: { + "Authorization": `Token ${token}` + } + }).text() + fs.writeFileSync(path.join(__dirname, translationsPODir, domain + '-' + code + '.po'), file) +} + + +const gt = new Gettext() + +locales.forEach((locale) => { + let json = {} + const fileName = `${domain}-${locale}.po` + const translationsFilePath = path.join(translationsPODir, fileName) + const translationsContent = fs.readFileSync(translationsFilePath) + + const parsedTranslations = po.parse(translationsContent) + const a = gt.gettext(parsedTranslations) + for (const [key, value] of Object.entries(a)) { + if (key === 'translations') { + for (const [key1, value1] of Object.entries(value)) { + if (key1 !== '') { + for (const [key2, value2] of Object.entries(value1)) { + json[value2.msgctxt] = value2.msgstr[0] + } + } + } + } + } + fs.writeFileSync(`${translationsDir}/${locale}.json`, JSON.stringify(json)) +}) \ No newline at end of file diff --git a/apps/i18n/package.json b/apps/i18n/package.json new file mode 100644 index 000000000..bb4534514 --- /dev/null +++ b/apps/i18n/package.json @@ -0,0 +1,15 @@ +{ + "name": "i18n-converter", + "description": "Convert Weblate translations to sveltekit-i18n", + "license": "Apache-2.0", + "scripts": { + "translate": "node index.mjs" + }, + "type": "module", + "dependencies": { + "node-gettext": "3.0.0", + "gettext-parser": "6.0.0", + "got": "12.3.1", + "dotenv": "16.0.2" + } +} \ No newline at end of file diff --git a/apps/ui/src/lib/components/svg/services/MeiliSearch.svelte b/apps/ui/src/lib/components/svg/services/MeiliSearch.svelte index c3cd9b1f1..033fba82f 100644 --- a/apps/ui/src/lib/components/svg/services/MeiliSearch.svelte +++ b/apps/ui/src/lib/components/svg/services/MeiliSearch.svelte @@ -4,7 +4,7 @@ {:else if type === 'searxng'} +{:else if type === 'weblate'} + {/if} diff --git a/apps/ui/src/lib/components/svg/services/Weblate.svelte b/apps/ui/src/lib/components/svg/services/Weblate.svelte new file mode 100644 index 000000000..25b5a837a --- /dev/null +++ b/apps/ui/src/lib/components/svg/services/Weblate.svelte @@ -0,0 +1,61 @@ + + + diff --git a/apps/ui/src/lib/components/svg/services/index.ts b/apps/ui/src/lib/components/svg/services/index.ts index 15fa32f90..22f7702c9 100644 --- a/apps/ui/src/lib/components/svg/services/index.ts +++ b/apps/ui/src/lib/components/svg/services/index.ts @@ -16,4 +16,5 @@ export { default as Fider } from './Fider.svelte'; export { default as Appwrite } from './Appwrite.svelte'; export { default as Moodle } from './Moodle.svelte'; export { default as GlitchTip } from './GlitchTip.svelte'; -export { default as Searxng } from './Searxng.svelte'; \ No newline at end of file +export { default as Searxng } from './Searxng.svelte'; +export { default as Weblate } from './Weblate.svelte'; \ No newline at end of file diff --git a/apps/ui/src/routes/applications/[id]/__layout.svelte b/apps/ui/src/routes/applications/[id]/__layout.svelte index 83f66dc6f..4cf80ef4d 100644 --- a/apps/ui/src/routes/applications/[id]/__layout.svelte +++ b/apps/ui/src/routes/applications/[id]/__layout.svelte @@ -62,9 +62,7 @@ import { t } from '$lib/translations'; import { appSession, disabledButton, status, location, setLocation, addToast } from '$lib/store'; import { errorNotification, handlerNotFoundLoad } from '$lib/common'; - import Loading from '$lib/components/Loading.svelte'; - let loading = false; let statusInterval: any; $disabledButton = !$appSession.isAdmin || @@ -78,7 +76,10 @@ async function handleDeploySubmit(forceRebuild = false) { try { - const { buildId } = await post(`/applications/${id}/deploy`, { ...application, forceRebuild }); + const { buildId } = await post(`/applications/${id}/deploy`, { + ...application, + forceRebuild + }); addToast({ message: $t('application.deployment_queued'), type: 'success' @@ -98,22 +99,41 @@ async function deleteApplication(name: string) { const sure = confirm($t('application.confirm_to_delete', { name })); if (sure) { - loading = true; + $status.application.initialLoading = true; try { await del(`/applications/${id}`, { id }); return await goto(`/applications`); } catch (error) { return errorNotification(error); + } finally { + $status.application.initialLoading = false; } } } + async function restartApplication() { + try { + $status.application.initialLoading = true; + $status.application.loading = true; + await post(`/applications/${id}/restart`, {}); + } catch (error) { + return errorNotification(error); + } finally { + $status.application.initialLoading = false; + $status.application.loading = false; + await getStatus(); + } + } async function stopApplication() { try { - loading = true; + $status.application.initialLoading = true; + $status.application.loading = true; await post(`/applications/${id}/stop`, {}); - return window.location.reload(); } catch (error) { return errorNotification(error); + } finally { + $status.application.initialLoading = false; + $status.application.loading = false; + await getStatus(); } } async function getStatus() { @@ -152,209 +172,136 @@
+ {/if} - {/if} - - {#if $status.application.isExited} - + - - - - - - - - {/if} - {#if $status.application.initialLoading} - - {:else if $status.application.isRunning} + + + + + + + + + + {:else if $status.application.isRunning} + + +
handleDeploySubmit(true)}> - handleDeploySubmit(true)}> - -
- {:else} -
handleDeploySubmit(false)}> - -
- {/if} - -
- - - - - + + + + {:else} +
handleDeploySubmit(false)}> +
+ {/if} + +
+ + + + + + + + + + + + + + + + + {#if !application.settings.isBot} - - + + + + + -
- - {/if} +
+ + + + +
+ + diff --git a/apps/ui/src/routes/databases/[id]/__layout.svelte b/apps/ui/src/routes/databases/[id]/__layout.svelte index fd4a81817..90cbe3e99 100644 --- a/apps/ui/src/routes/databases/[id]/__layout.svelte +++ b/apps/ui/src/routes/databases/[id]/__layout.svelte @@ -60,11 +60,9 @@ import { errorNotification, handlerNotFoundLoad } from '$lib/common'; import { appSession, status, disabledButton } from '$lib/store'; import DeleteIcon from '$lib/components/DeleteIcon.svelte'; - import Loading from '$lib/components/Loading.svelte'; import { onDestroy, onMount } from 'svelte'; const { id } = $page.params; - let loading = false; let statusInterval: any = false; $disabledButton = !$appSession.isAdmin; @@ -72,36 +70,41 @@ async function deleteDatabase() { const sure = confirm(`Are you sure you would like to delete '${database.name}'?`); if (sure) { - loading = true; + $status.database.initialLoading = true; try { await del(`/databases/${database.id}`, { id: database.id }); return await goto('/databases'); } catch (error) { return errorNotification(error); } finally { - loading = false; + $status.database.initialLoading = false; } } } async function stopDatabase() { const sure = confirm($t('database.confirm_stop', { name: database.name })); if (sure) { - loading = true; + $status.database.initialLoading = true; try { await post(`/databases/${database.id}/stop`, {}); - return window.location.reload(); } catch (error) { return errorNotification(error); + } finally { + $status.database.initialLoading = false; } } } async function startDatabase() { - loading = true; + $status.database.initialLoading = true; + $status.database.loading = true; try { await post(`/databases/${database.id}/start`, {}); - return window.location.reload(); } catch (error) { return errorNotification(error); + } finally { + $status.database.initialLoading = false; + $status.database.loading = false; + await getStatus(); } } async function getStatus() { @@ -137,120 +140,36 @@ {#if id !== 'new'} {/if} diff --git a/apps/ui/src/routes/services/[id]/_Secret.svelte b/apps/ui/src/routes/services/[id]/_Secret.svelte index c347dcabc..fa1f95318 100644 --- a/apps/ui/src/routes/services/[id]/_Secret.svelte +++ b/apps/ui/src/routes/services/[id]/_Secret.svelte @@ -1,4 +1,4 @@ - @@ -19,76 +53,33 @@
GlitchTip
-
-
Settings
-
-
changeSettings('enableOpenUserRegistration')} + title="Enable Open User Registration" + description={''} + /> +
Email settings
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
changeSettings('emailSmtpUseTls')} + title="Use TLS for SMTP" description={''} />
@@ -96,32 +87,84 @@
changeSettings('emailSmtpUseSsl')} + title="Use SSL for SMTP" description={''} />
-
- - -
- -
- +
- + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
@@ -130,35 +173,31 @@
- +
- +
- +
- +
- +
- + diff --git a/apps/ui/src/routes/services/[id]/_Services/_Services.svelte b/apps/ui/src/routes/services/[id]/_Services/_Services.svelte index b7d365680..929a24170 100644 --- a/apps/ui/src/routes/services/[id]/_Services/_Services.svelte +++ b/apps/ui/src/routes/services/[id]/_Services/_Services.svelte @@ -30,6 +30,7 @@ import Appwrite from './_Appwrite.svelte'; import Moodle from './_Moodle.svelte'; import Searxng from './_Searxng.svelte'; + import Weblate from './_Weblate.svelte'; const { id } = $page.params; $: isDisabled = @@ -405,6 +406,8 @@ {:else if service.type === 'searxng'} + {:else if service.type === 'weblate'} + {/if}
diff --git a/apps/ui/src/routes/services/[id]/_Services/_Weblate.svelte b/apps/ui/src/routes/services/[id]/_Services/_Weblate.svelte new file mode 100644 index 000000000..fdbd055c1 --- /dev/null +++ b/apps/ui/src/routes/services/[id]/_Services/_Weblate.svelte @@ -0,0 +1,66 @@ + + +
+
Weblate
+
+ +
+ + +
+ +
+
PostgreSQL
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/apps/ui/src/routes/services/[id]/__layout.svelte b/apps/ui/src/routes/services/[id]/__layout.svelte index 6f37692eb..a19a5fb84 100644 --- a/apps/ui/src/routes/services/[id]/__layout.svelte +++ b/apps/ui/src/routes/services/[id]/__layout.svelte @@ -56,7 +56,6 @@ diff --git a/apps/ui/src/routes/services/[id]/secrets.svelte b/apps/ui/src/routes/services/[id]/secrets.svelte index 78271ca7d..a0929bf2c 100644 --- a/apps/ui/src/routes/services/[id]/secrets.svelte +++ b/apps/ui/src/routes/services/[id]/secrets.svelte @@ -25,14 +25,47 @@ import { page } from '$app/stores'; import { get } from '$lib/api'; import { t } from '$lib/translations'; + import pLimit from 'p-limit'; import ServiceLinks from './_ServiceLinks.svelte'; + import { addToast } from '$lib/store'; + import { saveSecret } from './utils'; + const limit = pLimit(1); const { id } = $page.params; + let batchSecrets = ''; async function refreshSecrets() { const data = await get(`/services/${id}/secrets`); secrets = [...data.secrets]; } + async function getValues(e: any) { + e.preventDefault(); + const eachValuePair = batchSecrets.split('\n'); + const batchSecretsPairs = eachValuePair + .filter((secret) => !secret.startsWith('#') && secret) + .map((secret) => { + const [name, ...rest] = secret.split('='); + const value = rest.join('='); + const cleanValue = value?.replaceAll('"', '') || ''; + return { + name, + value: cleanValue, + isNew: !secrets.find((secret: any) => name === secret.name) + }; + }); + + await Promise.all( + batchSecretsPairs.map(({ name, value, isNew }) => + limit(() => saveSecret({ name, value, serviceId: id, isNew })) + ) + ); + batchSecrets = ''; + await refreshSecrets(); + addToast({ + message: 'Secrets saved.', + type: 'success' + }); + }
+

Paste .env file

+
+