initial commit
This commit is contained in:
commit
e77adbfc9f
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
S3_HOST=...
|
||||||
|
S3_ACCESS_KEY=...
|
||||||
|
S3_SECRET_KEY=...
|
||||||
|
S3_BUCKET=...
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
.env
|
0
Dockerfile
Normal file
0
Dockerfile
Normal file
38
README.md
Normal file
38
README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
3766
package-lock.json
generated
Normal file
3766
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "dailies",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-auto": "^6.0.0",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"vite": "^6.2.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ed25519": "^2.3.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
|
"pako": "^2.1.0",
|
||||||
|
"tailwindcss": "^4.1.10",
|
||||||
|
"@aws-sdk/client-s3": "^3.828.0"
|
||||||
|
}
|
||||||
|
}
|
64
src/app.css
Normal file
64
src/app.css
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
0% {
|
||||||
|
scale: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation-name: fade-in;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-duration: 350ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-in {
|
||||||
|
scale: 0;
|
||||||
|
animation-name: scale-in;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
animation-timing-function: cubic-bezier(.17,.67,.41,1.3);
|
||||||
|
animation-duration: 350ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation-name: blink;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-duration: 1s;
|
||||||
|
}
|
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
12
src/app.html
Normal file
12
src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div id="root" style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
26
src/lib/auth.ts
Normal file
26
src/lib/auth.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { decodeKeyVault, deriveKeyVault, encodeKeyVault, type KeyVault } from "./crypto";
|
||||||
|
|
||||||
|
export function loggedIn() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return !!sessionStorage['_vault'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(passkey: string): Promise<KeyVault> {
|
||||||
|
const keyVault = await deriveKeyVault(passkey);
|
||||||
|
sessionStorage['_vault'] = encodeKeyVault(keyVault);
|
||||||
|
return keyVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKeyVault(): KeyVault | null {
|
||||||
|
try {
|
||||||
|
return decodeKeyVault(sessionStorage['_vault']);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
delete sessionStorage['_vault'];
|
||||||
|
}
|
||||||
|
|
125
src/lib/crypto.ts
Normal file
125
src/lib/crypto.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import * as ed from '@noble/ed25519';
|
||||||
|
import { sha512 } from '@noble/hashes/sha2';
|
||||||
|
import { gzip, ungzip } from 'pako';
|
||||||
|
|
||||||
|
ed.etc.sha512Sync = sha512;
|
||||||
|
|
||||||
|
const SALT = 'SONRI-KEY-SALT';
|
||||||
|
|
||||||
|
export type KeyVault = Awaited<ReturnType<typeof deriveKeyVault>>;
|
||||||
|
|
||||||
|
// Helper: convert string to Uint8Array
|
||||||
|
function strToUint8(str: string) {
|
||||||
|
return new TextEncoder().encode(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ToStr(uint8: Uint8Array) {
|
||||||
|
return new TextDecoder().decode(uint8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uint8ToHex(uint8: Uint8Array) {
|
||||||
|
return Array.from(uint8)
|
||||||
|
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToUint8(hex: string): Uint8Array {
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
throw new Error('Hex string must have an even length');
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = hex.length / 2;
|
||||||
|
const uint8 = new Uint8Array(length);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
uint8[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uint8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ToBase64(uint8: Uint8Array) {
|
||||||
|
return atob(uint8ToStr(uint8));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToUint8(base64: string) {
|
||||||
|
return strToUint8(btoa(base64));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveKeyVault(secret: string) {
|
||||||
|
let hash = strToUint8(secret), last = hash;
|
||||||
|
for (let i = 0; i < 10000; i++) {
|
||||||
|
const hashInput = Uint8Array.from([...strToUint8(SALT), ...hash]);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-512', hashInput);
|
||||||
|
last = hash;
|
||||||
|
hash = new Uint8Array(hashBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const symmetricKey = last.slice(0, 32);
|
||||||
|
const privateKey = hash.slice(0, 32);
|
||||||
|
const publicKey = ed.getPublicKey(privateKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey,
|
||||||
|
privateKey,
|
||||||
|
symmetricKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeKeyVault(keyVault: KeyVault) {
|
||||||
|
const jsonStr = JSON.stringify(Object.fromEntries(Object.entries(keyVault).map(([key, value]) => {
|
||||||
|
return [key, uint8ToHex(value)];
|
||||||
|
})));
|
||||||
|
|
||||||
|
return uint8ToHex(gzip(strToUint8(jsonStr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeKeyVault(encoded: string) {
|
||||||
|
const jsonStr = uint8ToStr(ungzip(hexToUint8(encoded)));
|
||||||
|
|
||||||
|
return Object.fromEntries(Object.entries(JSON.parse(jsonStr)).map(([key, value]) => {
|
||||||
|
return [key, hexToUint8(value as string)];
|
||||||
|
})) as KeyVault;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function encrypt(vault: KeyVault, data: Uint8Array) {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
vault.symmetricKey,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
return Uint8Array.from([...iv, ...new Uint8Array(encryptedBuffer)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(vault: KeyVault, cipher: Uint8Array) {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
vault.symmetricKey,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const iv = cipher.slice(0, 12);
|
||||||
|
cipher = cipher.slice(12);
|
||||||
|
|
||||||
|
const dataBuffer = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipher
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Uint8Array(dataBuffer);
|
||||||
|
}
|
4
src/lib/date.ts
Normal file
4
src/lib/date.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export function dateDiff(a: Date, b: Date) {
|
||||||
|
const diff = (a.getTime() - b.getTime()) - 60 * 60 * 1000;
|
||||||
|
return new Date(diff);
|
||||||
|
}
|
36
src/lib/ffmpeg.server.ts
Normal file
36
src/lib/ffmpeg.server.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { hashBuffer } from './hash';
|
||||||
|
|
||||||
|
export async function convertVideo(video: Uint8Array) {
|
||||||
|
let random = Math.floor(Math.random() * 10000000);
|
||||||
|
let hash = await hashBuffer(video.buffer);
|
||||||
|
|
||||||
|
const tempInPath = `/tmp/${hash}-${random}.in.webm`;
|
||||||
|
const tempOutPath = `/tmp/${hash}-${random}.out.webm`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tempInPath, video);
|
||||||
|
|
||||||
|
const status = await new Promise((resolve, reject) => {
|
||||||
|
const child = exec(`ffmpeg -i "${tempInPath}" -preset ultrafast -vf "scale=320:480,eq=saturation=1.4,unsharp=5:5:1.5:5:5:0.0" -b:v 420k -b:a 64k "${tempOutPath}"`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(child.exitCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status) return null;
|
||||||
|
|
||||||
|
const result = await fs.readFile(tempOutPath);
|
||||||
|
const resultUint8 = new Uint8Array(result);
|
||||||
|
|
||||||
|
return resultUint8;
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tempInPath).catch((_) => {});
|
||||||
|
await fs.unlink(tempOutPath).catch((_) => {});
|
||||||
|
}
|
||||||
|
}
|
5
src/lib/hash.ts
Normal file
5
src/lib/hash.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export async function hashBuffer(buffer: ArrayBufferLike) {
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer as any);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 10);
|
||||||
|
}
|
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
12
src/lib/storage.server.ts
Normal file
12
src/lib/storage.server.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { S3_HOST, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET } from '$env/static/private';
|
||||||
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
|
export const s3 = new S3Client({
|
||||||
|
region: 'eu-central',
|
||||||
|
endpoint: `https://${S3_HOST}`,
|
||||||
|
forcePathStyle: true, // Required for many custom S3 endpoints
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: S3_ACCESS_KEY,
|
||||||
|
secretAccessKey: S3_SECRET_KEY
|
||||||
|
}
|
||||||
|
});
|
3
src/routes/(area)/+layout.svelte
Normal file
3
src/routes/(area)/+layout.svelte
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="min-h-full bg-[#e7f7df]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
58
src/routes/(area)/log/+page.svelte
Normal file
58
src/routes/(area)/log/+page.svelte
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getKeyVault, loggedIn } from "$lib/auth";
|
||||||
|
import { decrypt, uint8ToHex } from "$lib/crypto";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let entryUrls = $state<any[] | null>(null);
|
||||||
|
let videoSrc = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
if (!loggedIn()) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = getKeyVault();
|
||||||
|
fetch(`/api/log?pubkey=${encodeURIComponent(uint8ToHex(vault!.publicKey))}`).then(async res => {
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
entryUrls = (await res.json() as any[]).sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
console.log(entryUrls);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = async (date: string) => {
|
||||||
|
videoSrc = null;
|
||||||
|
|
||||||
|
const entry = entryUrls?.find((e) => e.date === date);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const res = await fetch(entry.url);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const cipherBuffer = await res.bytes();
|
||||||
|
console.log(cipherBuffer);
|
||||||
|
|
||||||
|
const vault = getKeyVault()!;
|
||||||
|
const videoData = await decrypt(vault, new Uint8Array(cipherBuffer));
|
||||||
|
|
||||||
|
const blob = new Blob([videoData], { type: 'video/webm' });
|
||||||
|
videoSrc = URL.createObjectURL(blob);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-screen p-4 flex flex-col items-center">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<input type="date"
|
||||||
|
class="px-4 py-2 rounded-full bg-white min-w-0"
|
||||||
|
onchange={(e) => update((e.target as any).value)}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grow w-full pt-3 flex flex-col items-center justify-items-stretch overflow-hidden">
|
||||||
|
<video src={videoSrc} controls
|
||||||
|
class="h-full w-full min-w-0 min-h-0 max-w-[70vh] object-cover rounded-[40px] bg-[#303030]">
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
34
src/routes/(area)/login/+page.svelte
Normal file
34
src/routes/(area)/login/+page.svelte
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { loggedIn, login } from "$lib/auth";
|
||||||
|
import { deriveKeyVault, encodeKeyVault } from "$lib/crypto";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
const onSubmit = async (e: SubmitEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target as any);
|
||||||
|
const passkey = formData.get('passkey') as string;
|
||||||
|
await login(passkey);
|
||||||
|
window.location.href = '/today';
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
if (loggedIn()) {
|
||||||
|
window.location.href = '/today';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full grid place-items-center p-6">
|
||||||
|
<form method="POST" class="p-6 rounded-3xl bg-white flex flex-col gap-2"
|
||||||
|
onsubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold">Login</h1>
|
||||||
|
<input type="password" name="passkey" placeholder="Passkey"
|
||||||
|
class="px-3 py-1 rounded-3xl outline-none bg-[#00000010]">
|
||||||
|
<button type="submit" class="px-3 py-1 rounded-3xl cursor-pointer bg-[#2596be] cusor-pointer text-white">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
12
src/routes/(area)/settings/+page.svelte
Normal file
12
src/routes/(area)/settings/+page.svelte
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { loggedIn } from "$lib/auth";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
if (!loggedIn()) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
7
src/routes/(area)/today/+page.server.ts
Normal file
7
src/routes/(area)/today/+page.server.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { Actions } from "./$types";
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async (event) => {
|
||||||
|
|
||||||
|
},
|
||||||
|
};
|
185
src/routes/(area)/today/+page.svelte
Normal file
185
src/routes/(area)/today/+page.svelte
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { hashBuffer } from "$lib/hash";
|
||||||
|
import { dateDiff } from "$lib/date";
|
||||||
|
import { getKeyVault, loggedIn } from "$lib/auth";
|
||||||
|
import { encrypt, uint8ToHex } from "$lib/crypto";
|
||||||
|
|
||||||
|
type StatusT = "pending" | "active" | "error";
|
||||||
|
let status = $state<StatusT>("pending");
|
||||||
|
let errors = $state<string[]>([]);
|
||||||
|
|
||||||
|
let now = $state(new Date());
|
||||||
|
let recordingStart = $state<Date | null>(null);
|
||||||
|
let recordingURL = $state<string | null>(null);
|
||||||
|
|
||||||
|
let video: HTMLVideoElement, finalVideo: HTMLVideoElement;
|
||||||
|
let recorder: MediaRecorder | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
if (!loggedIn()) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
now = new Date();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDTH = 320;
|
||||||
|
const HEIGHT = 480;
|
||||||
|
const FPS = 24;
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { width: WIDTH, height: HEIGHT, frameRate: FPS, facingMode: 'user' },
|
||||||
|
audio: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
video.srcObject = stream;
|
||||||
|
|
||||||
|
recorder = new MediaRecorder(stream, { mimeType: 'video/webm; codecs="vp8"' });
|
||||||
|
recorder.addEventListener('dataavailable', async (e) => {
|
||||||
|
const inputBlob = new Blob([e.data], { type: 'video/webm' });
|
||||||
|
|
||||||
|
const convertRes = await fetch('/api/convert', {
|
||||||
|
method: 'POST',
|
||||||
|
body: inputBlob,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!convertRes.ok) return;
|
||||||
|
|
||||||
|
const convertBlob = await convertRes.blob();
|
||||||
|
recordingURL = URL.createObjectURL(convertBlob);
|
||||||
|
setTimeout(() => finalVideo.scrollIntoView(), 150);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
recordingStart = new Date();
|
||||||
|
recorder?.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
recordingStart = null;
|
||||||
|
recorder?.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!recordingURL) return;
|
||||||
|
|
||||||
|
const blob = await (await fetch(recordingURL)).blob();
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
console.log('buffer', buffer);
|
||||||
|
|
||||||
|
const vault = getKeyVault()!;
|
||||||
|
const encrypted = await encrypt(vault, new Uint8Array(buffer));
|
||||||
|
console.log('encrypted', encrypted);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/today?pubkey=${uint8ToHex(vault.publicKey)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: encrypted,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = '/log';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<div class="h-screen p-4 min-h-0 grid place-items-center">
|
||||||
|
<video
|
||||||
|
autoplay muted bind:this={video}
|
||||||
|
class="opacity-0 h-full w-full min-h-0 max-w-[70vh] object-cover rounded-[40px] bg-[#303030] fade-in"
|
||||||
|
style="animation-delay: 500ms;"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 right-0 p-6 m-auto w-fit scale-in"
|
||||||
|
style="animation-delay: 50ms;"
|
||||||
|
>
|
||||||
|
<div class="rounded-3xl bg-white px-3 py-1 font-serif text-center w-fit">
|
||||||
|
<h1 class="font-bold text-xl">{now.toLocaleDateString(navigator.language, { dateStyle: 'long' })}</h1>
|
||||||
|
<h2 class="text-sm">{now.toLocaleTimeString(navigator.language)}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !!recordingStart}
|
||||||
|
<div class="h-1"></div>
|
||||||
|
<div class="rounded-3xl bg-white px-3 py-1 font-serif flex gap-1 items-center w-fit m-auto scale-in">
|
||||||
|
<svg class="blink" xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 0 24 24" width="24px" fill="red"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zm0-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>
|
||||||
|
<div>
|
||||||
|
<span class="text-md">
|
||||||
|
<!-- <span class="font-bold">Recording</span> -->
|
||||||
|
<span>{dateDiff(now, recordingStart).toLocaleTimeString(navigator.language, { minute: 'numeric', second: 'numeric' })}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 right-0 p-6 m-auto w-fit fade-in"
|
||||||
|
style="animation-delay: 50ms;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class={`font-serif px-5 py-3 rounded-3xl text-white font-bold text-2xl cursor-pointer ${recordingStart ? 'bg-[#e14c2f]' : 'bg-[#2596be]'}`}
|
||||||
|
style="transition-duration: 250ms;"
|
||||||
|
onclick={() => recordingStart ? stop() : start()}
|
||||||
|
>
|
||||||
|
{recordingStart ? 'Stop' : 'Start'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recordingURL}
|
||||||
|
<div class="h-8"></div>
|
||||||
|
|
||||||
|
<div class="p-4 min-h-0 grid place-items-center">
|
||||||
|
<video controls bind:this={finalVideo}
|
||||||
|
src={recordingURL}
|
||||||
|
class="opacity-0 h-full w-full max-w-[50vh] min-h-0 w-full object-cover rounded-[40px] bg-[#303030] fade-in"
|
||||||
|
style="aspect-ratio: 320 / 480;"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div class="h-2"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={`font-serif px-5 py-3 rounded-3xl text-white font-bold text-2xl cursor-pointer bg-[#2596be]`}
|
||||||
|
style="transition-duration: 250ms;"
|
||||||
|
onclick={submit}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- <canvas class="hidden" bind:this={renderCanvas}></canvas>
|
||||||
|
<canvas class="hidden" bind:this={captureCanvas}></canvas>
|
||||||
|
<video class="hidden" bind:this={sourceVideo}>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video> -->
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
5
src/routes/+layout.svelte
Normal file
5
src/routes/+layout.svelte
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import "../app.css";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<slot />
|
17
src/routes/+page.svelte
Normal file
17
src/routes/+page.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { loggedIn } from "$lib/auth";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
console.log('test', loggedIn());
|
||||||
|
|
||||||
|
if (loggedIn()) {
|
||||||
|
window.location.href = '/today';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>Welcome to SvelteKit</h1>
|
||||||
|
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
17
src/routes/api/convert/+server.ts
Normal file
17
src/routes/api/convert/+server.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { convertVideo } from "$lib/ffmpeg.server";
|
||||||
|
import { error } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const bodyBuffer = await request.arrayBuffer();
|
||||||
|
const bodyUint8 = new Uint8Array(bodyBuffer);
|
||||||
|
|
||||||
|
const convertedUint8 = await convertVideo(bodyUint8);
|
||||||
|
if (!convertedUint8) error(500);
|
||||||
|
|
||||||
|
return new Response(convertedUint8, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'video/webm',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
34
src/routes/api/log/+server.ts
Normal file
34
src/routes/api/log/+server.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { s3 } from '$lib/storage.server';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||||
|
import { S3_BUCKET, S3_HOST } from '$env/static/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const pubkeyHex = url.searchParams.get('pubkey')?.trim()?.toLowerCase();
|
||||||
|
if (!pubkeyHex || !/^[0-9a-f]{64}$/.test(pubkeyHex)) error(400);
|
||||||
|
|
||||||
|
const objects = [];
|
||||||
|
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Prefix: `${pubkeyHex}/`,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const res = await s3.send(command);
|
||||||
|
objects.push(...(res.Contents ?? []));
|
||||||
|
|
||||||
|
continuationToken = res.NextContinuationToken;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
|
return json(objects.filter(obj => !!obj.Key).map(obj => {
|
||||||
|
return {
|
||||||
|
date: obj.Key!.split('/').at(-1),
|
||||||
|
url: `https://${S3_BUCKET}.${S3_HOST}/${encodeURIComponent(obj.Key!)}`
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
29
src/routes/api/today/+server.ts
Normal file
29
src/routes/api/today/+server.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { convertVideo } from "$lib/ffmpeg.server";
|
||||||
|
import { error, json, text } from "@sveltejs/kit";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { S3_BUCKET } from "$env/static/private";
|
||||||
|
import { s3 } from "$lib/storage.server";
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, url }) => {
|
||||||
|
const pubkeyHex = url.searchParams.get('pubkey')?.trim()?.toLowerCase();
|
||||||
|
if (!pubkeyHex || !/^[0-9a-f]{64}$/.test(pubkeyHex)) error(400);
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const bodyBuffer = await request.arrayBuffer();
|
||||||
|
const bodyUint8 = new Uint8Array(bodyBuffer);
|
||||||
|
|
||||||
|
console.log(today, bodyUint8.byteLength);
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET,
|
||||||
|
Key: `${pubkeyHex}/${today}`,
|
||||||
|
Body: bodyUint8,
|
||||||
|
ContentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3.send(command);
|
||||||
|
|
||||||
|
return text('ok');
|
||||||
|
}
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
18
svelte.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit(), tailwindcss()],
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user