diff --git a/DysonNetwork.Sphere/Client/.editorconfig b/DysonNetwork.Sphere/Client/.editorconfig
new file mode 100644
index 0000000..5a5809d
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/.editorconfig
@@ -0,0 +1,9 @@
+[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
+charset = utf-8
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+end_of_line = lf
+max_line_length = 100
diff --git a/DysonNetwork.Sphere/Client/.gitattributes b/DysonNetwork.Sphere/Client/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/DysonNetwork.Sphere/Client/.gitignore b/DysonNetwork.Sphere/Client/.gitignore
new file mode 100644
index 0000000..20c3f25
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/.gitignore
@@ -0,0 +1,31 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+**/node_modules/highlight.js/
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo
diff --git a/DysonNetwork.Sphere/Client/.prettierrc.json b/DysonNetwork.Sphere/Client/.prettierrc.json
new file mode 100644
index 0000000..29a2402
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://json.schemastore.org/prettierrc",
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 100
+}
diff --git a/DysonNetwork.Sphere/Client/.vscode/extensions.json b/DysonNetwork.Sphere/Client/.vscode/extensions.json
new file mode 100644
index 0000000..3f84126
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/.vscode/extensions.json
@@ -0,0 +1,9 @@
+{
+ "recommendations": [
+ "Vue.volar",
+ "dbaeumer.vscode-eslint",
+ "EditorConfig.EditorConfig",
+ "oxc.oxc-vscode",
+ "esbenp.prettier-vscode"
+ ]
+}
diff --git a/DysonNetwork.Sphere/Client/env.d.ts b/DysonNetwork.Sphere/Client/env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/DysonNetwork.Sphere/Client/eslint.config.ts b/DysonNetwork.Sphere/Client/eslint.config.ts
new file mode 100644
index 0000000..07ad90a
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/eslint.config.ts
@@ -0,0 +1,31 @@
+import { globalIgnores } from 'eslint/config'
+import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
+import pluginVue from 'eslint-plugin-vue'
+import pluginOxlint from 'eslint-plugin-oxlint'
+import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
+
+// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
+// import { configureVueProject } from '@vue/eslint-config-typescript'
+// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
+// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
+
+export default defineConfigWithVueTs(
+ {
+ name: 'app/files-to-lint',
+ files: ['**/*.{ts,mts,tsx,vue}'],
+ },
+
+ globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
+
+ pluginVue.configs['flat/essential'],
+ vueTsConfigs.recommended,
+ ...pluginOxlint.configs['flat/recommended'],
+ {
+ rules: {
+ 'vue/multi-word-component-names': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/ban-ts-comment': 'off',
+ },
+ },
+ skipFormatting,
+)
diff --git a/DysonNetwork.Sphere/Client/index.html b/DysonNetwork.Sphere/Client/index.html
new file mode 100644
index 0000000..f18006e
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Solar Network
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/package.json b/DysonNetwork.Sphere/Client/package.json
new file mode 100644
index 0000000..0d8e4be
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/package.json
@@ -0,0 +1,58 @@
+{
+ "name": "@solar-network/sphere",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "run-p type-check \"build-only {@}\" --",
+ "preview": "vite preview",
+ "build-only": "vite build",
+ "type-check": "vue-tsc --build",
+ "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
+ "lint:eslint": "eslint . --fix",
+ "lint": "run-s lint:*",
+ "format": "prettier --write src/"
+ },
+ "dependencies": {
+ "@fingerprintjs/fingerprintjs": "^4.6.2",
+ "@fontsource-variable/nunito": "^5.2.6",
+ "@hcaptcha/vue3-hcaptcha": "^1.3.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@tailwindcss/vite": "^4.1.11",
+ "@vueuse/core": "^13.5.0",
+ "aspnet-prerendering": "^3.0.1",
+ "cfturnstile-vue3": "^2.0.0",
+ "chart.js": "^4.5.0",
+ "dayjs": "^1.11.13",
+ "marked": "^16.1.1",
+ "pinia": "^3.0.3",
+ "tailwindcss": "^4.1.11",
+ "tus-js-client": "^4.3.1",
+ "vue": "^3.5.17",
+ "vue-chartjs": "^5.3.2",
+ "vue-router": "^4.5.1"
+ },
+ "devDependencies": {
+ "@tsconfig/node22": "^22.0.2",
+ "@types/node": "^22.16.4",
+ "@vicons/material": "^0.13.0",
+ "@vitejs/plugin-vue": "^6.0.0",
+ "@vitejs/plugin-vue-jsx": "^5.0.1",
+ "@vue/eslint-config-prettier": "^10.2.0",
+ "@vue/eslint-config-typescript": "^14.6.0",
+ "@vue/tsconfig": "^0.7.0",
+ "eslint": "^9.31.0",
+ "eslint-plugin-oxlint": "~1.1.0",
+ "eslint-plugin-vue": "~10.2.0",
+ "jiti": "^2.4.2",
+ "naive-ui": "^2.42.0",
+ "npm-run-all2": "^8.0.4",
+ "oxlint": "~1.1.0",
+ "prettier": "3.5.3",
+ "typescript": "~5.8.3",
+ "vite": "npm:rolldown-vite@latest",
+ "vite-plugin-vue-devtools": "^7.7.7",
+ "vue-tsc": "^2.2.12"
+ }
+}
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/Client/public/favicon.png b/DysonNetwork.Sphere/Client/public/favicon.png
new file mode 100755
index 0000000..0eeb8c1
Binary files /dev/null and b/DysonNetwork.Sphere/Client/public/favicon.png differ
diff --git a/DysonNetwork.Sphere/Client/public/image-broken.jpg b/DysonNetwork.Sphere/Client/public/image-broken.jpg
new file mode 100644
index 0000000..1fb6a0c
Binary files /dev/null and b/DysonNetwork.Sphere/Client/public/image-broken.jpg differ
diff --git a/DysonNetwork.Sphere/Client/src/assets/main.css b/DysonNetwork.Sphere/Client/src/assets/main.css
new file mode 100644
index 0000000..dcb13cc
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/assets/main.css
@@ -0,0 +1,10 @@
+@import "tailwindcss";
+@plugin "@tailwindcss/typography";
+
+@layer theme, base, components, utilities;
+
+@layer base {
+ body {
+ font-family: 'Nunito Variable', sans-serif;
+ }
+}
diff --git a/DysonNetwork.Sphere/Client/src/components/AttachmentItem.vue b/DysonNetwork.Sphere/Client/src/components/AttachmentItem.vue
new file mode 100644
index 0000000..b0e6ced
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/components/AttachmentItem.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/components/PostHeader.vue b/DysonNetwork.Sphere/Client/src/components/PostHeader.vue
new file mode 100644
index 0000000..2a33512
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/components/PostHeader.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+ {{ props.item.publisher.nick }}
+ @{{ props.item.publisher.name }}
+
+
+ {{ dayjs(props.item.created_at).utc().fromNow() }}
+ ยท
+ {{ new Date(props.item.created_at).toLocaleString() }}
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/components/PostItem.vue b/DysonNetwork.Sphere/Client/src/components/PostItem.vue
new file mode 100644
index 0000000..3aa4386
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/components/PostItem.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
{{ props.item.title }}
+
+ {{ props.item.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/layouts/default.vue b/DysonNetwork.Sphere/Client/src/layouts/default.vue
new file mode 100644
index 0000000..b1ccd23
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/layouts/default.vue
@@ -0,0 +1,115 @@
+
+
+
+ Solar Network
+
+
+ Account
+
+
+ {{ userStore.user.nick }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/main.ts b/DysonNetwork.Sphere/Client/src/main.ts
new file mode 100644
index 0000000..d535f8e
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/main.ts
@@ -0,0 +1,16 @@
+import '@fontsource-variable/nunito';
+
+import './assets/main.css'
+
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+
+import Root from './root.vue'
+import router from './router'
+
+const app = createApp(Root)
+
+app.use(createPinia())
+app.use(router)
+
+app.mount('#app')
diff --git a/DysonNetwork.Sphere/Client/src/root.vue b/DysonNetwork.Sphere/Client/src/root.vue
new file mode 100644
index 0000000..f838752
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/root.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/router/index.ts b/DysonNetwork.Sphere/Client/src/router/index.ts
new file mode 100644
index 0000000..1d13263
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/router/index.ts
@@ -0,0 +1,39 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { useUserStore } from '@/stores/user'
+import { useServicesStore } from '@/stores/services'
+
+const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes: [
+ {
+ path: '/',
+ name: 'index',
+ component: () => import('../views/index.vue'),
+ },
+ ],
+})
+
+router.beforeEach(async (to, from, next) => {
+ const userStore = useUserStore()
+ const servicesStore = useServicesStore()
+
+ // Initialize user state if not already initialized
+ if (!userStore.user) {
+ await userStore.fetchUser()
+ }
+
+ if (to.matched.some((record) => record.meta.requiresAuth) && !userStore.isAuthenticated) {
+ window.open(
+ servicesStore.getSerivceUrl(
+ 'DysonNetwork.Pass',
+ 'login?redirect=' + encodeURIComponent(window.location.href),
+ )!,
+ '_blank',
+ )
+ next('/')
+ } else {
+ next()
+ }
+})
+
+export default router
diff --git a/DysonNetwork.Sphere/Client/src/stores/services.ts b/DysonNetwork.Sphere/Client/src/stores/services.ts
new file mode 100644
index 0000000..2ef28c9
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/stores/services.ts
@@ -0,0 +1,27 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export const useServicesStore = defineStore('services', () => {
+ const services = ref>({})
+
+ async function fetchServices() {
+ try {
+ const response = await fetch('/cgi/.well-known/services')
+ if (!response.ok) {
+ throw new Error('Network response was not ok')
+ }
+ const data = await response.json()
+ services.value = data
+ } catch (error) {
+ console.error('Failed to fetch services:', error)
+ services.value = {}
+ }
+ }
+
+ function getSerivceUrl(serviceName: string, ...parts: string[]): string | null {
+ const baseUrl = services.value[serviceName] || null
+ return baseUrl ? `${baseUrl}/${parts.join('/')}` : null
+ }
+
+ return { services, fetchServices, getSerivceUrl }
+})
diff --git a/DysonNetwork.Sphere/Client/src/stores/user.ts b/DysonNetwork.Sphere/Client/src/stores/user.ts
new file mode 100644
index 0000000..7dca8a6
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/stores/user.ts
@@ -0,0 +1,65 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useUserStore = defineStore('user', () => {
+ // State
+ const user = ref(null)
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ // Getters
+ const isAuthenticated = computed(() => !!user.value)
+
+ // Actions
+ async function fetchUser(reload = true) {
+ if (!reload && user.value) return
+ isLoading.value = true
+ error.value = null
+ try {
+ const response = await fetch('/cgi/id/accounts/me', {
+ credentials: 'include',
+ })
+
+ if (!response.ok) {
+ // If the token is invalid, clear it and the user state
+ throw new Error('Failed to fetch user information.')
+ }
+
+ user.value = await response.json()
+ } catch (e: any) {
+ error.value = e.message
+ user.value = null // Clear user data on error
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ function initialize() {
+ const allowedOrigin = import.meta.env.DEV ? window.location.origin : 'https://id.solian.app'
+ window.addEventListener('message', (event) => {
+ // IMPORTANT: Always check the origin of the message for security!
+ // This prevents malicious scripts from sending fake login status updates.
+ // Ensure event.origin exactly matches your identity service's origin.
+ if (event.origin !== allowedOrigin) {
+ console.warn(`[SYNC] Message received from unexpected origin: ${event.origin}. Ignoring.`)
+ return // Ignore messages from unknown origins
+ }
+
+ // Check if the message is the type we're expecting
+ if (event.data && event.data.type === 'DY:LOGIN_STATUS_CHANGE') {
+ const { loggedIn } = event.data
+ console.log(`[SYNC] Received login status change: ${loggedIn}`)
+ fetchUser() // Re-fetch user data on login status change
+ }
+ })
+ }
+
+ return {
+ user,
+ isLoading,
+ error,
+ isAuthenticated,
+ fetchUser,
+ initialize,
+ }
+})
diff --git a/DysonNetwork.Sphere/Client/src/views/index.vue b/DysonNetwork.Sphere/Client/src/views/index.vue
new file mode 100644
index 0000000..8e57671
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/views/index.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+ Welcome to the Solar Network
+ The open social network. Friendly to everyone.
+
+
+ Loading...
+
+ v{{ version.version }} @
+ {{ version.commit.substring(0, 6) }}
+ {{ version.updatedAt }}
+
+
+
+
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/views/not-found.vue b/DysonNetwork.Sphere/Client/src/views/not-found.vue
new file mode 100644
index 0000000..b5c8da9
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/views/not-found.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/DysonNetwork.Sphere/Client/src/views/secure.ts b/DysonNetwork.Sphere/Client/src/views/secure.ts
new file mode 100644
index 0000000..1d8a352
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/src/views/secure.ts
@@ -0,0 +1,94 @@
+export async function downloadAndDecryptFile(
+ url: string,
+ password: string,
+ fileName: string,
+ onProgress?: (progress: number) => void,
+): Promise {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
+
+ const contentLength = +(response.headers.get('Content-Length') || 0)
+ const reader = response.body!.getReader()
+ const chunks: Uint8Array[] = []
+ let received = 0
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ if (value) {
+ chunks.push(value)
+ received += value.length
+ if (contentLength && onProgress) {
+ onProgress(received / contentLength)
+ }
+ }
+ }
+
+ const fullBuffer = new Uint8Array(received)
+ let offset = 0
+ for (const chunk of chunks) {
+ fullBuffer.set(chunk, offset)
+ offset += chunk.length
+ }
+
+ const decryptedBytes = await decryptFile(fullBuffer, password)
+
+ // Create a blob and trigger a download
+ const blob = new Blob([decryptedBytes])
+ const downloadUrl = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = downloadUrl
+ a.download = fileName
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(downloadUrl)
+}
+
+export async function decryptFile(fileBuffer: Uint8Array, password: string): Promise {
+ const salt = fileBuffer.slice(0, 16)
+ const nonce = fileBuffer.slice(16, 28)
+ const tag = fileBuffer.slice(28, 44)
+ const ciphertext = fileBuffer.slice(44)
+
+ const enc = new TextEncoder()
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ enc.encode(password),
+ { name: 'PBKDF2' },
+ false,
+ ['deriveKey'],
+ )
+ const key = await crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['decrypt'],
+ )
+
+ const fullCiphertext = new Uint8Array(ciphertext.length + tag.length)
+ fullCiphertext.set(ciphertext)
+ fullCiphertext.set(tag, ciphertext.length)
+
+ let decrypted: ArrayBuffer
+ try {
+ decrypted = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: nonce, tagLength: 128 },
+ key,
+ fullCiphertext,
+ )
+ } catch {
+ throw new Error('Incorrect password or corrupted file.')
+ }
+
+ const magic = new TextEncoder().encode('DYSON1')
+ const decryptedBytes = new Uint8Array(decrypted)
+ for (let i = 0; i < magic.length; i++) {
+ if (decryptedBytes[i] !== magic[i]) {
+ throw new Error('Incorrect password or corrupted file.')
+ }
+ }
+
+ return decryptedBytes.slice(magic.length)
+}
diff --git a/DysonNetwork.Sphere/Client/tsconfig.app.json b/DysonNetwork.Sphere/Client/tsconfig.app.json
new file mode 100644
index 0000000..d0f8430
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/tsconfig.app.json
@@ -0,0 +1,12 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "./**/*.d.ts"],
+ "exclude": ["src/**/__tests__/*"],
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/DysonNetwork.Sphere/Client/tsconfig.json b/DysonNetwork.Sphere/Client/tsconfig.json
new file mode 100644
index 0000000..66b5e57
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ]
+}
diff --git a/DysonNetwork.Sphere/Client/tsconfig.node.json b/DysonNetwork.Sphere/Client/tsconfig.node.json
new file mode 100644
index 0000000..a83dfc9
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/tsconfig.node.json
@@ -0,0 +1,19 @@
+{
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "include": [
+ "vite.config.*",
+ "vitest.config.*",
+ "cypress.config.*",
+ "nightwatch.conf.*",
+ "playwright.config.*",
+ "eslint.config.*"
+ ],
+ "compilerOptions": {
+ "noEmit": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["node"]
+ }
+}
diff --git a/DysonNetwork.Sphere/Client/vite.config.ts b/DysonNetwork.Sphere/Client/vite.config.ts
new file mode 100644
index 0000000..387d074
--- /dev/null
+++ b/DysonNetwork.Sphere/Client/vite.config.ts
@@ -0,0 +1,32 @@
+import { fileURLToPath, URL } from 'node:url'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import vueDevTools from 'vite-plugin-vue-devtools'
+import tailwindcss from '@tailwindcss/vite'
+
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
+
+// https://vite.dev/config/
+export default defineConfig({
+ base: '/',
+ plugins: [vue(), vueJsx(), vueDevTools(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:5071',
+ changeOrigin: true,
+ },
+ '/cgi': {
+ target: 'http://localhost:5071',
+ changeOrigin: true,
+ }
+ },
+ },
+})
diff --git a/DysonNetwork.Sphere/Dockerfile b/DysonNetwork.Sphere/Dockerfile
index d604165..5fcc394 100644
--- a/DysonNetwork.Sphere/Dockerfile
+++ b/DysonNetwork.Sphere/Dockerfile
@@ -1,9 +1,28 @@
+# Stage 1: Base runtime image
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
+# Stage 2: Build SPA
+FROM node:22-alpine AS spa-builder
+WORKDIR /src
+
+# Copy package files for SPA
+COPY ["DysonNetwork.Sphere/Client/package.json", "DysonNetwork.Sphere/Client/package-lock.json*", "./Client/"]
+
+# Install SPA dependencies
+WORKDIR /src/Client
+RUN npm install
+
+# Copy SPA source
+COPY ["DysonNetwork.Sphere/Client/", "./"]
+
+# Build SPA
+RUN npm run build
+
+# Stage 3: Build .NET application
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
@@ -16,9 +35,12 @@ COPY ["DysonNetwork.Shared/DysonNetwork.Shared.csproj", "DysonNetwork.Shared/"]
# Restore packages
RUN dotnet restore "DysonNetwork.Sphere/DysonNetwork.Sphere.csproj"
-# Copy everything except Pass project's config files
+# Copy everything else and build
COPY . .
+# Copy built SPA to wwwroot
+COPY --from=spa-builder /src/Client/dist /src/DysonNetwork.Sphere/wwwroot/dist
+
# Remove Pass project's config files to prevent conflicts
RUN rm -f /src/DysonNetwork.Pass/appsettings*.json /src/DysonNetwork.Pass/version.json
@@ -26,16 +48,24 @@ RUN rm -f /src/DysonNetwork.Pass/appsettings*.json /src/DysonNetwork.Pass/versio
RUN mkdir -p /src/DysonNetwork.Sphere/bin/Release/net9.0/zh-hans \
&& mkdir -p /src/DysonNetwork.Pass/bin/Release/net9.0/zh-hans
+# Build the application
WORKDIR "/src/DysonNetwork.Sphere"
-RUN dotnet build "DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/build
+RUN dotnet build "DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/build \
+ -p:TypeScriptCompileBlocked=true \
+ -p:UseRazorBuildServer=false
+# Stage 4: Publish
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
# Ensure the target directory for localized resources exists
RUN mkdir -p /app/publish/zh-Hans
-RUN dotnet publish "./DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+RUN dotnet publish "DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/publish \
+ -p:TypeScriptCompileBlocked=true \
+ -p:UseRazorBuildServer=false \
+ /p:UseAppHost=false
+# Final stage: Runtime
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
-ENTRYPOINT ["dotnet", "DysonNetwork.Sphere.dll"]
+ENTRYPOINT ["dotnet", "DysonNetwork.Sphere.dll"]
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs
index f8b9c9d..9521387 100644
--- a/DysonNetwork.Sphere/Program.cs
+++ b/DysonNetwork.Sphere/Program.cs
@@ -45,4 +45,6 @@ using (var scope = app.Services.CreateScope())
// Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration);
+app.MapGatewayProxy();
+
app.Run();
\ No newline at end of file
diff --git a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs
index 190d06a..4448298 100644
--- a/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs
+++ b/DysonNetwork.Sphere/Startup/ServiceCollectionExtensions.cs
@@ -52,13 +52,15 @@ public static class ServiceCollectionExtensions
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
+ }).ConfigureApplicationPartManager(opts =>
+ {
+ var mockingPart = opts.ApplicationParts.FirstOrDefault(a => a.Name == "DysonNetwork.Pass");
+ if (mockingPart != null)
+ opts.ApplicationParts.Remove(mockingPart);
});
services.AddRazorPages();
-
- services.AddGrpc(options =>
- {
- options.EnableDetailedErrors = true;
- });
+
+ services.AddGrpc(options => { options.EnableDetailedErrors = true; });
services.Configure(options =>
{