Chocobozzz/PeerTube

View on GitHub
server/core/controllers/api/config.ts

Summary

Maintainability
F
3 days
Test Coverage
import express from 'express'
import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js'
import { ClientHtml } from '../../lib/html/client-html.js'
import {
  apiRateLimiter,
  asyncMiddleware,
  authenticate,
  ensureUserHasRight,
  openapiOperationDoc,
  updateAvatarValidator,
  updateBannerValidator
} from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
import { getServerActor } from '@server/models/application/application.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { ModelCache } from '@server/models/shared/model-cache.js'

const configRouter = express.Router()

configRouter.use(apiRateLimiter)

const auditLogger = auditLoggerFactory('config')

configRouter.get('/',
  openapiOperationDoc({ operationId: 'getConfig' }),
  asyncMiddleware(getConfig)
)

configRouter.get('/about',
  openapiOperationDoc({ operationId: 'getAbout' }),
  asyncMiddleware(getAbout)
)

configRouter.get('/custom',
  openapiOperationDoc({ operationId: 'getCustomConfig' }),
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  getCustomConfig
)

configRouter.put('/custom',
  openapiOperationDoc({ operationId: 'putCustomConfig' }),
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  ensureConfigIsEditable,
  customConfigUpdateValidator,
  asyncMiddleware(updateCustomConfig)
)

configRouter.delete('/custom',
  openapiOperationDoc({ operationId: 'delCustomConfig' }),
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  ensureConfigIsEditable,
  asyncMiddleware(deleteCustomConfig)
)

// ---------------------------------------------------------------------------

configRouter.post('/instance-banner/pick',
  authenticate,
  createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  updateBannerValidator,
  asyncMiddleware(updateInstanceImageFactory(ActorImageType.BANNER))
)

configRouter.delete('/instance-banner',
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  asyncMiddleware(deleteInstanceImageFactory(ActorImageType.BANNER))
)

// ---------------------------------------------------------------------------

configRouter.post('/instance-avatar/pick',
  authenticate,
  createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  updateAvatarValidator,
  asyncMiddleware(updateInstanceImageFactory(ActorImageType.AVATAR))
)

configRouter.delete('/instance-avatar',
  authenticate,
  ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
  asyncMiddleware(deleteInstanceImageFactory(ActorImageType.AVATAR))
)

// ---------------------------------------------------------------------------

async function getConfig (req: express.Request, res: express.Response) {
  const json = await ServerConfigManager.Instance.getServerConfig(req.ip)

  return res.json(json)
}

async function getAbout (req: express.Request, res: express.Response) {
  const serverActor = await getServerActor()

  const about: About = {
    instance: {
      name: CONFIG.INSTANCE.NAME,
      shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
      description: CONFIG.INSTANCE.DESCRIPTION,
      terms: CONFIG.INSTANCE.TERMS,
      codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,

      hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,

      creationReason: CONFIG.INSTANCE.CREATION_REASON,
      moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
      administrator: CONFIG.INSTANCE.ADMINISTRATOR,
      maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
      businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,

      languages: CONFIG.INSTANCE.LANGUAGES,
      categories: CONFIG.INSTANCE.CATEGORIES,

      banners: serverActor.Banners.map(b => b.toFormattedJSON()),
      avatars: serverActor.Avatars.map(a => a.toFormattedJSON())
    }
  }

  return res.json(about)
}

function getCustomConfig (req: express.Request, res: express.Response) {
  const data = customConfig()

  return res.json(data)
}

async function deleteCustomConfig (req: express.Request, res: express.Response) {
  await remove(CONFIG.CUSTOM_FILE)

  auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))

  await reloadConfig()
  ClientHtml.invalidateCache()

  const data = customConfig()

  return res.json(data)
}

async function updateCustomConfig (req: express.Request, res: express.Response) {
  const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())

  // camelCase to snake_case key + Force number conversion
  const toUpdateJSON = convertCustomConfigBody(req.body)

  await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })

  await reloadConfig()
  ClientHtml.invalidateCache()

  const data = customConfig()

  auditLogger.update(
    getAuditIdFromRes(res),
    new CustomConfigAuditView(data),
    oldCustomConfigAuditKeys
  )

  return res.json(data)
}

// ---------------------------------------------------------------------------

function updateInstanceImageFactory (imageType: ActorImageType_Type) {
  return async (req: express.Request, res: express.Response) => {
    const field = imageType === ActorImageType.BANNER
      ? 'bannerfile'
      : 'avatarfile'

    const imagePhysicalFile = req.files[field][0]

    await updateLocalActorImageFiles({
      accountOrChannel: (await getServerActorWithUpdatedImages(imageType)).Account,
      imagePhysicalFile,
      type: imageType,
      sendActorUpdate: false
    })

    ClientHtml.invalidateCache()
    ModelCache.Instance.clearCache('server-account')

    return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
  }
}

function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
  return async (req: express.Request, res: express.Response) => {
    await deleteLocalActorImageFile((await getServerActorWithUpdatedImages(imageType)).Account, imageType)

    ClientHtml.invalidateCache()
    ModelCache.Instance.clearCache('server-account')

    return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
  }
}

async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) {
  const serverActor = await getServerActor()
  const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB

  if (imageType === ActorImageType.BANNER) serverActor.Banners = updatedImages
  else serverActor.Avatars = updatedImages

  return serverActor
}

// ---------------------------------------------------------------------------

export {
  configRouter
}

// ---------------------------------------------------------------------------

function customConfig (): CustomConfig {
  return {
    instance: {
      name: CONFIG.INSTANCE.NAME,
      shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
      description: CONFIG.INSTANCE.DESCRIPTION,
      terms: CONFIG.INSTANCE.TERMS,
      codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,

      creationReason: CONFIG.INSTANCE.CREATION_REASON,
      moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
      administrator: CONFIG.INSTANCE.ADMINISTRATOR,
      maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
      businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
      hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,

      languages: CONFIG.INSTANCE.LANGUAGES,
      categories: CONFIG.INSTANCE.CATEGORIES,

      isNSFW: CONFIG.INSTANCE.IS_NSFW,
      defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,

      defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,

      customizations: {
        css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS,
        javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
      }
    },
    theme: {
      default: CONFIG.THEME.DEFAULT
    },
    services: {
      twitter: {
        username: CONFIG.SERVICES.TWITTER.USERNAME
      }
    },
    client: {
      videos: {
        miniature: {
          preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
        }
      },
      menu: {
        login: {
          redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
        }
      }
    },
    cache: {
      previews: {
        size: CONFIG.CACHE.PREVIEWS.SIZE
      },
      captions: {
        size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
      },
      torrents: {
        size: CONFIG.CACHE.TORRENTS.SIZE
      },
      storyboards: {
        size: CONFIG.CACHE.STORYBOARDS.SIZE
      }
    },
    signup: {
      enabled: CONFIG.SIGNUP.ENABLED,
      limit: CONFIG.SIGNUP.LIMIT,
      requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
      requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
      minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
    },
    admin: {
      email: CONFIG.ADMIN.EMAIL
    },
    contactForm: {
      enabled: CONFIG.CONTACT_FORM.ENABLED
    },
    user: {
      history: {
        videos: {
          enabled: CONFIG.USER.HISTORY.VIDEOS.ENABLED
        }
      },
      videoQuota: CONFIG.USER.VIDEO_QUOTA,
      videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
      defaultChannelName: CONFIG.USER.DEFAULT_CHANNEL_NAME
    },
    videoChannels: {
      maxPerUser: CONFIG.VIDEO_CHANNELS.MAX_PER_USER
    },
    transcoding: {
      enabled: CONFIG.TRANSCODING.ENABLED,
      originalFile: {
        keep: CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP
      },
      remoteRunners: {
        enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
      },
      allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
      allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
      threads: CONFIG.TRANSCODING.THREADS,
      concurrency: CONFIG.TRANSCODING.CONCURRENCY,
      profile: CONFIG.TRANSCODING.PROFILE,
      resolutions: {
        '0p': CONFIG.TRANSCODING.RESOLUTIONS['0p'],
        '144p': CONFIG.TRANSCODING.RESOLUTIONS['144p'],
        '240p': CONFIG.TRANSCODING.RESOLUTIONS['240p'],
        '360p': CONFIG.TRANSCODING.RESOLUTIONS['360p'],
        '480p': CONFIG.TRANSCODING.RESOLUTIONS['480p'],
        '720p': CONFIG.TRANSCODING.RESOLUTIONS['720p'],
        '1080p': CONFIG.TRANSCODING.RESOLUTIONS['1080p'],
        '1440p': CONFIG.TRANSCODING.RESOLUTIONS['1440p'],
        '2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
      },
      alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
      webVideos: {
        enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
      },
      hls: {
        enabled: CONFIG.TRANSCODING.HLS.ENABLED
      }
    },
    live: {
      enabled: CONFIG.LIVE.ENABLED,
      allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
      latencySetting: {
        enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
      },
      maxDuration: CONFIG.LIVE.MAX_DURATION,
      maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
      maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
      transcoding: {
        enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
        remoteRunners: {
          enabled: CONFIG.LIVE.TRANSCODING.REMOTE_RUNNERS.ENABLED
        },
        threads: CONFIG.LIVE.TRANSCODING.THREADS,
        profile: CONFIG.LIVE.TRANSCODING.PROFILE,
        resolutions: {
          '144p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['144p'],
          '240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
          '360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
          '480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'],
          '720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'],
          '1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
          '1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'],
          '2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
        },
        alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION
      }
    },
    videoStudio: {
      enabled: CONFIG.VIDEO_STUDIO.ENABLED,
      remoteRunners: {
        enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
      }
    },
    videoFile: {
      update: {
        enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
      }
    },
    import: {
      videos: {
        concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
        http: {
          enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
        },
        torrent: {
          enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
        }
      },
      videoChannelSynchronization: {
        enabled: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED,
        maxPerUser: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER
      },
      users: {
        enabled: CONFIG.IMPORT.USERS.ENABLED
      }
    },
    export: {
      users: {
        enabled: CONFIG.EXPORT.USERS.ENABLED,
        exportExpiration: CONFIG.EXPORT.USERS.EXPORT_EXPIRATION,
        maxUserVideoQuota: CONFIG.EXPORT.USERS.MAX_USER_VIDEO_QUOTA
      }
    },
    trending: {
      videos: {
        algorithms: {
          enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
          default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
        }
      }
    },
    autoBlacklist: {
      videos: {
        ofUsers: {
          enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
        }
      }
    },
    followers: {
      instance: {
        enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
        manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
      }
    },
    followings: {
      instance: {
        autoFollowBack: {
          enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED
        },

        autoFollowIndex: {
          enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.ENABLED,
          indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
        }
      }
    },
    broadcastMessage: {
      enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
      message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
      level: CONFIG.BROADCAST_MESSAGE.LEVEL,
      dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
    },
    search: {
      remoteUri: {
        users: CONFIG.SEARCH.REMOTE_URI.USERS,
        anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
      },
      searchIndex: {
        enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
        url: CONFIG.SEARCH.SEARCH_INDEX.URL,
        disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
        isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
      }
    },
    storyboards: {
      enabled: CONFIG.STORYBOARDS.ENABLED
    }
  }
}

function convertCustomConfigBody (body: CustomConfig) {
  function keyConverter (k: string) {
    // Transcoding resolutions exception
    if (/^\d{3,4}p$/.exec(k)) return k
    if (k === '0p') return k

    return snakeCase(k)
  }

  function valueConverter (v: any) {
    if (validator.default.isNumeric(v + '')) return parseInt('' + v, 10)

    return v
  }

  return objectConverter(body, keyConverter, valueConverter)
}