Merge branch 'imputnet:main' into main

This commit is contained in:
EDM115 2024-09-24 12:24:47 +02:00 committed by GitHub
commit 6f9b2f28b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 948 additions and 474 deletions

4
.github/test.sh vendored
View File

@ -14,7 +14,7 @@ waitport() {
test_api() {
waitport 3000
curl -m 3 http://localhost:3000/
API_RESPONSE=$(curl -m 3 http://localhost:3000/ \
API_RESPONSE=$(curl -m 10 http://localhost:3000/ \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
@ -24,7 +24,7 @@ test_api() {
STATUS=$(echo "$API_RESPONSE" | jq -r .status)
STREAM_URL=$(echo "$API_RESPONSE" | jq -r .url)
[ "$STATUS" = tunnel ] || exit 1;
S=$(curl -I -m 3 "$STREAM_URL")
S=$(curl -I -m 10 "$STREAM_URL")
CONTENT_LENGTH=$(echo "$S" \
| grep -i content-length \

View File

@ -1,7 +1,7 @@
{
"name": "@imput/cobalt-api",
"description": "save what you love",
"version": "10.0.0",
"version": "10.1.0",
"author": "imput",
"exports": "./src/cobalt.js",
"type": "module",

View File

@ -34,10 +34,15 @@ const env = {
externalProxy: process.env.API_EXTERNAL_PROXY,
turnstileSitekey: process.env.TURNSTILE_SITEKEY,
turnstileSecret: process.env.TURNSTILE_SECRET,
jwtSecret: process.env.JWT_SECRET,
jwtLifetime: process.env.JWT_EXPIRY || 120,
sessionEnabled: process.env.TURNSTILE_SITEKEY
&& process.env.TURNSTILE_SECRET
&& process.env.JWT_SECRET,
enabledServices,
}

View File

@ -49,6 +49,7 @@ export const runAPI = (express, app, __dirname) => {
url: env.apiURL,
startTime: `${startTimestamp}`,
durationLimit: env.durationLimit,
turnstileSitekey: env.sessionEnabled ? env.turnstileSitekey : undefined,
services: [...env.enabledServices].map(e => {
return friendlyServiceName(e);
}),
@ -106,7 +107,17 @@ export const runAPI = (express, app, __dirname) => {
app.use('/tunnel', apiLimiterStream);
app.post('/', (req, res, next) => {
if (!env.turnstileSecret || !env.jwtSecret) {
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
}
if (!acceptRegex.test(req.header('Content-Type'))) {
return fail(res, "error.api.header.content_type");
}
next();
});
app.post('/', (req, res, next) => {
if (!env.sessionEnabled) {
return next();
}
@ -128,14 +139,6 @@ export const runAPI = (express, app, __dirname) => {
return fail(res, "error.api.auth.jwt.invalid");
}
if (!acceptRegex.test(req.header('Accept'))) {
return fail(res, "error.api.header.accept");
}
if (!acceptRegex.test(req.header('Content-Type'))) {
return fail(res, "error.api.header.content_type");
}
req.authorized = true;
} catch {
return fail(res, "error.api.generic");
@ -156,7 +159,7 @@ export const runAPI = (express, app, __dirname) => {
});
app.post("/session", async (req, res) => {
if (!env.turnstileSecret || !env.jwtSecret) {
if (!env.sessionEnabled) {
return fail(res, "error.api.auth.not_configured")
}

View File

@ -65,3 +65,14 @@ export function merge(a, b) {
return a;
}
export function splitFilenameExtension(filename) {
const parts = filename.split('.');
const ext = parts.pop();
if (!parts.length) {
return [ ext, "" ]
} else {
return [ parts.join('.'), ext ]
}
}

View File

@ -2,16 +2,24 @@ export default (f, style, isAudioOnly, isAudioMuted) => {
let filename = '';
let infoBase = [f.service, f.id];
let classicTags = infoBase.concat([
f.resolution,
f.youtubeFormat,
]);
let basicTags = [f.qualityLabel, f.youtubeFormat];
let classicTags = [...infoBase];
let basicTags = [];
const title = `${f.title} - ${f.author}`;
if (f.resolution) {
classicTags.push(f.resolution);
}
if (f.qualityLabel) {
basicTags.push(f.qualityLabel);
}
if (f.youtubeFormat) {
classicTags.push(f.youtubeFormat);
basicTags.push(f.youtubeFormat);
}
if (isAudioMuted) {
classicTags.push("mute");
basicTags.push("mute");

View File

@ -3,6 +3,7 @@ import createFilename from "./create-filename.js";
import { createResponse } from "./request.js";
import { audioIgnore } from "./service-config.js";
import { createStream } from "../stream/manage.js";
import { splitFilenameExtension } from "../misc/utils.js";
export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disableMetadata, filenameStyle, twitterGif, requestIP, audioBitrate, alwaysProxy }) {
let action,
@ -32,10 +33,11 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
}
if (action === "muteVideo" && isAudioMuted && !r.filenameAttributes) {
const parts = r.filename.split(".");
const ext = parts.pop();
defaultParams.filename = `${parts.join(".")}_mute.${ext}`;
const [ name, ext ] = splitFilenameExtension(r.filename);
defaultParams.filename = `${name}_mute.${ext}`;
} else if (action === "gif") {
const [ name ] = splitFilenameExtension(r.filename);
defaultParams.filename = `${name}.gif`;
}
switch (action) {
@ -148,6 +150,7 @@ export default function({ r, host, audioFormat, isAudioOnly, isAudioMuted, disab
case "streamable":
case "snapchat":
case "loom":
case "twitch":
responseType = "redirect";
break;
}

View File

@ -176,7 +176,7 @@ export const services = {
Object.values(services).forEach(service => {
service.patterns = service.patterns.map(
pattern => new UrlPattern(pattern, {
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.'
segmentValueCharset: UrlPattern.defaultOptions.segmentValueCharset + '@\\.:'
})
)
})

View File

@ -2,8 +2,8 @@ import HLS from "hls-parser";
import { cobaltUserAgent } from "../../config.js";
import { createStream } from "../../stream/manage.js";
const extractVideo = async ({ getPost, filename }) => {
const urlMasterHLS = getPost?.thread?.post?.embed?.playlist;
const extractVideo = async ({ media, filename }) => {
const urlMasterHLS = media?.playlist;
if (!urlMasterHLS) return { error: "fetch.empty" };
if (!urlMasterHLS.startsWith("https://video.bsky.app/")) return { error: "fetch.empty" };
@ -77,14 +77,37 @@ export default async function ({ user, post, alwaysProxy }) {
}
}).then(r => r.json()).catch(() => {});
if (!getPost || getPost?.error) return { error: "fetch.empty" };
if (!getPost) return { error: "fetch.empty" };
if (getPost.error) {
switch (getPost.error) {
case "NotFound":
case "InternalServerError":
return { error: "content.post.unavailable" };
case "InvalidRequest":
return { error: "link.unsupported" };
default:
return { error: "fetch.empty" };
}
}
const embedType = getPost?.thread?.post?.embed?.$type;
const filename = `bluesky_${user}_${post}`;
if (embedType === "app.bsky.embed.video#view") {
return extractVideo({ getPost, filename });
return extractVideo({
media: getPost.thread?.post?.embed,
filename,
})
}
if (embedType === "app.bsky.embed.recordWithMedia#view") {
return extractVideo({
media: getPost.thread?.post?.embed?.media,
filename,
})
}
if (embedType === "app.bsky.embed.images#view") {
return extractImages({ getPost, filename, alwaysProxy });
}

View File

@ -18,8 +18,8 @@ const codecMatch = {
},
av1: {
videoCodec: "av01",
audioCodec: "mp4a",
container: "mp4"
audioCodec: "opus",
container: "webm"
},
vp9: {
videoCodec: "vp9",

View File

@ -291,7 +291,7 @@ const convertGif = (streamInfo, res) => {
const [,,, muxOutput] = process.stdio;
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename.split('.')[0] + ".gif"));
res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename));
pipe(muxOutput, res, shutdown);

View File

@ -1,7 +1,7 @@
import { existsSync, unlinkSync, appendFileSync } from "fs";
import { createInterface } from "readline";
import { Cyan, Bright } from "./misc/console-text.js";
import { loadJSON } from "./misc/load-from-fs.js";
import { Cyan, Bright } from "../misc/console-text.js";
import { loadJSON } from "../misc/load-from-fs.js";
import { execSync } from "child_process";
const { version } = loadJSON("./package.json");

View File

@ -1113,7 +1113,7 @@
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
"status": "redirect"
}
},
{
@ -1401,7 +1401,16 @@
"bsky": [
{
"name": "horizontal video",
"url": "https://bsky.app/profile/samuel.bsky.team/post/3l2udah76ch2c",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3giwtwp222m",
"params": {},
"expected": {
"code": 200,
"status": "tunnel"
}
},
{
"name": "horizontal video, recordWithMedia",
"url": "https://bsky.app/profile/did:plc:ywbm3iywnhzep3ckt6efhoh7/post/3l3wonhk23g2i",
"params": {},
"expected": {
"code": 200,
@ -1410,7 +1419,7 @@
},
{
"name": "vertical video",
"url": "https://bsky.app/profile/samuel.bsky.team/post/3l2uftgmitr2p",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {},
"expected": {
"code": 200,
@ -1419,7 +1428,7 @@
},
{
"name": "vertical video (muted)",
"url": "https://bsky.app/profile/samuel.bsky.team/post/3l2uftgmitr2p",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {
"downloadMode": "mute"
},
@ -1430,7 +1439,7 @@
},
{
"name": "vertical video (audio)",
"url": "https://bsky.app/profile/samuel.bsky.team/post/3l2uftgmitr2p",
"url": "https://bsky.app/profile/did:plc:oisofpd7lj26yvgiivf3lxsi/post/3l3jhpomhjk2m",
"params": {
"downloadMode": "audio"
},
@ -1441,7 +1450,7 @@
},
{
"name": "single image",
"url": "https://bsky.app/profile/thehardyboycats.bsky.social/post/3l33flpoygt26",
"url": "https://bsky.app/profile/did:plc:k4a7d65fcyevbrnntjxh57go/post/3l33flpoygt26",
"params": {},
"expected": {
"code": 200,
@ -1450,12 +1459,21 @@
},
{
"name": "several images",
"url": "https://bsky.app/profile/tracey-m.bsky.social/post/3kzxuxbiul626",
"url": "https://bsky.app/profile/did:plc:rai7s6su2sy22ss7skouedl7/post/3kzxuxbiul626",
"params": {},
"expected": {
"code": 200,
"status": "picker"
}
},
{
"name": "deleted post/invalid user",
"url": "https://bsky.app/profile/notreal.bsky.team/post/3l2udah76ch2c",
"params": {},
"expected": {
"code": 400,
"status": "error"
}
}
]
}

View File

@ -32,13 +32,19 @@ cobalt package will update automatically thanks to watchtower.
it's highly recommended to use a reverse proxy (such as nginx) if you want your instance to face the public internet. look up tutorials online.
## using regular node.js (useful for local development)
setup script installs all needed `npm` dependencies, but you have to install `node.js` *(version 18 or above)* and `git` yourself.
## run cobalt api outside of docker (useful for local development)
requirements:
- node.js >= 18
- git
- pnpm
1. clone the repo: `git clone https://github.com/imputnet/cobalt`.
2. run setup script and follow instructions: `npm run setup`. you need to host api and web instances separately, so pick whichever applies.
3. run cobalt via `npm start`.
4. done.
2. go to api/src directory: `cd cobalt/api/src`.
3. install dependencies: `pnpm install`.
4. create `.env` file in the same directory.
5. add needed environment variables to `.env` file. only `API_URL` is required to run cobalt.
- if you don't know what api url to use for local development, use `http://localhost:9000/`.
6. run cobalt: `pnpm start`.
### ubuntu 22.04 workaround
`nscd` needs to be installed and running so that the `ffmpeg-static` binary can resolve DNS ([#101](https://github.com/imputnet/cobalt/issues/101#issuecomment-1494822258)):

1
packages/api-client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -84,41 +84,25 @@ importers:
packages/version-info: {}
web:
dependencies:
devDependencies:
'@eslint/js':
specifier: ^9.5.0
version: 9.8.0
'@fontsource-variable/noto-sans-mono':
specifier: ^5.0.20
version: 5.0.20
'@fontsource/ibm-plex-mono':
specifier: ^5.0.13
version: 5.0.13
'@fontsource/redaction-10':
specifier: ^5.0.2
version: 5.0.2
'@imput/libav.js-remux-cli':
specifier: ^5.5.6
version: 5.5.6
'@imput/version-info':
specifier: workspace:^
version: link:../packages/version-info
'@tabler/icons-svelte':
specifier: 3.6.0
version: 3.6.0(svelte@4.2.18)
'@vitejs/plugin-basic-ssl':
specifier: ^1.1.0
version: 1.1.0(vite@5.3.5(@types/node@20.14.14))
mime:
specifier: ^4.0.4
version: 4.0.4
sveltekit-i18n:
specifier: ^2.4.2
version: 2.4.2(svelte@4.2.18)
ts-deepmerge:
specifier: ^7.0.0
version: 7.0.1
devDependencies:
'@eslint/js':
specifier: ^9.5.0
version: 9.8.0
'@fontsource/redaction-10':
specifier: ^5.0.2
version: 5.0.2
'@sveltejs/adapter-static':
specifier: ^3.0.2
version: 3.0.2(@sveltejs/kit@2.5.19(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14)))
@ -128,6 +112,9 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^3.0.0
version: 3.1.1(svelte@4.2.18)(vite@5.3.5(@types/node@20.14.14))
'@tabler/icons-svelte':
specifier: 3.6.0
version: 3.6.0(svelte@4.2.18)
'@types/eslint__js':
specifier: ^8.42.3
version: 8.42.3
@ -137,9 +124,15 @@ importers:
'@types/node':
specifier: ^20.14.10
version: 20.14.14
'@vitejs/plugin-basic-ssl':
specifier: ^1.1.0
version: 1.1.0(vite@5.3.5(@types/node@20.14.14))
compare-versions:
specifier: ^6.1.0
version: 6.1.1
dotenv:
specifier: ^16.0.1
version: 16.4.5
eslint:
specifier: ^8.57.0
version: 8.57.0
@ -149,6 +142,9 @@ importers:
mdsvex:
specifier: ^0.11.2
version: 0.11.2(svelte@4.2.18)
mime:
specifier: ^4.0.4
version: 4.0.4
svelte:
specifier: ^4.2.7
version: 4.2.18
@ -158,6 +154,12 @@ importers:
svelte-preprocess:
specifier: ^6.0.2
version: 6.0.2(postcss@8.4.40)(svelte@4.2.18)(typescript@5.5.4)
sveltekit-i18n:
specifier: ^2.4.2
version: 2.4.2(svelte@4.2.18)
ts-deepmerge:
specifier: ^7.0.1
version: 7.0.1
tslib:
specifier: ^2.4.1
version: 2.6.3

View File

@ -47,7 +47,7 @@ and for nerds, we have a giant list of backend changes (that we are also excited
this update allows us to actually innovate and develop new & exciting features. we are no longer held back by the legacy codebase. first feature of such kind is on-device remuxing. go check it out!
oh yeah, we now have 2.5 million monthly users. kind of insane.
oh yeah, we now have over 2 million monthly users. kind of insane.
we hope you enjoy this update as much as we enjoyed making it. it was a really fun summer project for both of us.

View File

@ -9,6 +9,7 @@ hey, ever wanted to download a youtube video without a hassle? cobalt is here to
not only that, but it also introduces features never before seen in a downloader, such as youtube dub downloads! read below to see what's up :)
<span class="text-backdrop">tl;dr:</span>
- audio in youtube videos FINALLY no longer gets cut off.
- you now can pick any video resolution you want (from 360p to 8k) and any possible youtube video codec (h264/av1/vp9).
- you now can download youtube videos with dubs in your native language. just check settings > audio.

View File

@ -8,6 +8,7 @@ banner:
hey, notice anything different? well, at very least the page loaded way faster! this update includes many improvements and fixes, but also some new features.
<span class="text-backdrop">tl;dr:</span>
- twitter retweet links are now supported.
- all vimeo videos should now be possible to download.
- you now can download audio from vimeo.

View File

@ -8,6 +8,7 @@ banner:
something many of you've been waiting for is finally here! try it out and let me know what you think :)
<span class='text-backdrop'>tl;dr:</span>
- added experimental instagram support! download any reels or videos you like, and make sure to report any issues you encounter. yes, you can convert either to audio.
- fixed support for on.soundcloud links.
- added share button to "how to save?" popup.

View File

@ -10,6 +10,7 @@ hey! long time no see, hopefully over 40 changes will make up for it :)
cobalt now has an official community discord server. you can go there for news, support, or just to chat. [go check it out!](https://discord.gg/pQPt8HBUPu)
<span class='text-backdrop'>tl;dr</span>
- new infra, new hosting structure, new main instance api url. developers, [get it here](https://github.com/imputnet/cobalt/blob/main/docs/api.md).
- added support for pinterest, vine archive, tumblr audio, youtube vr videos.
- better web app performance and look.

View File

@ -8,6 +8,7 @@ banner:
hey! this update is huge and mostly aimed to refresh the ui, but there are also some other nice fixes/additions. read below for more info :)
<span class="text-backdrop">tl;dr:</span>
- entirety of web app has been refreshed. it's more prettier and optimized than ever, both on phone and desktop.
- if you're on ios, try adding cobalt to home screen! it'll look and act like a native app.
- all soundcloud links are now supported and audio quality is higher than before.

View File

@ -1,5 +1,6 @@
{
"link_area": "link input area",
"link_area.turnstile": "link input area. checking if you're not a robot.",
"clear_input": "clear input",
"download": "download",
"download.think": "processing the link...",

View File

@ -12,5 +12,19 @@
"community.twitter": "news account on twitter",
"community.github": "github repo",
"community.email": "support email",
"community.telegram": "news channel on telegram"
"community.telegram": "news channel on telegram",
"heading.general": "general terms",
"heading.licenses": "licenses",
"heading.summary": "best way to save what you love",
"heading.privacy": "leading privacy",
"heading.speed": "blazing speed",
"heading.community": "open community",
"heading.local": "on-device processing",
"heading.saving": "saving",
"heading.encryption": "encryption",
"heading.plausible": "anonymous traffic analytics",
"heading.cloudflare": "web privacy & security",
"heading.responsibility": "user responsibilities",
"heading.abuse": "reporting abuse"
}

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { contacts, docs } from "$lib/env";
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="meowbalt">
<SectionHeading
title={$t("general.meowbalt")}
sectionId="meowbalt"
/>
meowbalt is cobalt's speedy mascot. he is an extremely expressive cat that loves fast internet.
all amazing drawings of meowbalt that you see in cobalt were made by [GlitchyPSI](https://glitchypsi.xyz/).
he is also the original designer of the character.
you cannot use or modify GlitchyPSI's artworks of meowbalt without his explicit permission.
you cannot use or modify the meowbalt character design commercially or in any form that isn't fan art.
</section>
<section id="licenses">
<SectionHeading
title={$t("about.heading.licenses")}
sectionId="licenses"
/>
cobalt processing server is open source and licensed under [AGPL-3.0]({docs.apiLicense}).
cobalt frontend is [source first](https://sourcefirst.com/) and licensed under [CC-BY-NC-SA 4.0]({docs.webLicense}).
we decided to use this license to stop grifters from profiting off our work & from creating malicious clones that deceive people and hurt our public identity.
we rely on many open source libraries, create & distribute our own.
you can see the full list of dependencies on [github]({contacts.github}).
</section>

View File

@ -0,0 +1,79 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import { partners, contacts, docs } from "$lib/env";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="saving">
<SectionHeading
title={$t("about.heading.summary")}
sectionId="saving"
/>
cobalt lets you save anything from your favorite websites: video, audio, photos or gifs — cobalt can do it all!
no ads, trackers, or paywalls, no nonsense. just a convenient web app that works everywhere.
</section>
<section id="privacy">
<SectionHeading
title={$t("about.heading.privacy")}
sectionId="privacy"
/>
all requests to backend are anonymous and all tunnels are encrypted.
we have a strict zero log policy and don't track *anything* about individual people.
to avoid caching or storing downloaded files, cobalt processes them on-the-fly, sending processed pieces directly to client.
this technology is used when your request needs additional processing, such as when source service stores video & audio in separate files.
for even higher level of protection, you can [ask cobalt to always tunnel everything](/settings/privacy#tunnel).
when enabled, cobalt will proxy everything through itself. no one will know what you download, even your network provider/admin.
all they'll see is that you're using cobalt.
</section>
<section id="speed">
<SectionHeading
title={$t("about.heading.speed")}
sectionId="speed"
/>
since we don't rely on any existing downloaders and develop our own from ground up,
cobalt is extremely efficient and a processing server can run on basically any hardware.
main processing instances are hosted on several dedicated servers in several countries,
to reduce latency and distribute the traffic.
we constantly improve our infrastructure along with our long-standing partner, [royalehosting.net]({partners.royalehosting})!
you're in good hands, and will get what you need within seconds.
</section>
<section id="community">
<SectionHeading
title={$t("about.heading.community")}
sectionId="community"
/>
cobalt is used by countless artists, educators, and content creators to do what they love.
we're always on the line with our community and work together to create even more useful tools for them.
feel free to [join the conversation](/about/community)!
we believe that the future of the internet is open, which is why cobalt is [source first](https://sourcefirst.com/) and [easily self-hostable]({docs.instanceHosting}). you can [check the source code & contribute to cobalt]({contacts.github})
at any time, we welcome all contributions and suggestions.
you can use any processing instances hosted by the community, including your own.
if your friend hosts one, just ask them for a domain and [add it in instance settings](/settings/instances#community).
</section>
<section id="local">
<SectionHeading
title={$t("about.heading.local")}
sectionId="local"
/>
new features, such as [remuxing](/remux), work on-device.
on-device processing is efficient and never sends anything over the internet.
it perfectly aligns with our future goal of moving as much processing as possible to client.
</section>

View File

@ -0,0 +1,76 @@
<script lang="ts">
import env from "$lib/env";
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="general">
<SectionHeading
title={$t("about.heading.general")}
sectionId="general"
/>
cobalt's privacy policy is simple: we don't collect or store anything about you. what you do is solely your business, not ours or anyone else's.
these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
</section>
<section id="local">
<SectionHeading
title={$t("about.heading.local")}
sectionId="local"
/>
tools that use on-device processing work offline, locally, and never send any data anywhere. they are explicitly marked as such whenever applicable.
</section>
<section id="saving">
<SectionHeading
title={$t("about.heading.saving")}
sectionId="saving"
/>
when using saving functionality, in some cases cobalt will encrypt & temporarily store information needed for tunneling. it's stored in processing server's RAM for 90 seconds and irreversibly purged afterwards. no one has access to it, even instance owners, as long as they don't modify the official cobalt image.
processed/tunneled files are never cached anywhere. everything is tunneled live. cobalt's saving functionality is essentially a fancy proxy service.
</section>
<section id="encryption">
<SectionHeading
title={$t("about.heading.encryption")}
sectionId="encryption"
/>
temporarily stored tunnel data is encrypted using the AES-256 standard. decryption keys are only included in the access link and never logged/cached/stored anywhere. only the end user has access to the link & encryption keys. keys are generated uniquely for each requested tunnel.
</section>
{#if env.PLAUSIBLE_ENABLED}
<section id="plausible">
<SectionHeading
title={$t("about.heading.plausible")}
sectionId="plausible"
/>
for sake of privacy, we use [plausible's anonymous traffic analytics](https://plausible.io/) to get an approximate number of active cobalt users. no identifiable information about you or your requests is ever stored. all data is anonymized and aggregated. the plausible instance we use is hosted & managed by us.
plausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.
[learn more about plausible's dedication to privacy.](https://plausible.io/privacy-focused-web-analytics)
if you wish to opt out of anonymous analytics, you can do it in <a href="/settings/privacy#analytics">privacy settings</a>.
</section>
{/if}
<section id="cloudflare">
<SectionHeading
title={$t("about.heading.cloudflare")}
sectionId="cloudflare"
/>
we use cloudflare services for ddos & bot protection. we also use cloudflare pages for deploying & hosting the static web app. all of these are required to provide the best experience for everyone. it's the most private & reliable provider that we know of.
cloudflare is fully compliant with GDPR and HIPAA.
[learn more about cloudflare's dedication to privacy.](https://www.cloudflare.com/trust-hub/privacy-and-data-protection/)
</section>

View File

@ -0,0 +1,47 @@
<script lang="ts">
import { t } from "$lib/i18n/translations";
import SectionHeading from "$components/misc/SectionHeading.svelte";
</script>
<section id="general">
<SectionHeading
title={$t("about.heading.general")}
sectionId="general"
/>
these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
</section>
<section id="saving">
<SectionHeading
title={$t("about.heading.saving")}
sectionId="saving"
/>
saving functionality simplifies downloading content from the internet and takes zero liability for what the saved content is used for. processing servers work like advanced proxies and don't ever write any content to disk. everything is handled in RAM and permanently purged once the tunnel is done. we have no downloading logs and can't identify anyone.
[you can read more about how tunnels work in our privacy policy.](/about/privacy)
</section>
<section id="responsibility">
<SectionHeading
title={$t("about.heading.responsibility")}
sectionId="responsibility"
/>
you (end user) are responsible for what you do with our tools, how you use and distribute resulting content. please be mindful when using content of others and always credit original creators. make sure you don't violate any terms or licenses.
when used in educational purposes, always cite sources and credit original creators.
fair use and credits benefit everyone.
</section>
<section id="abuse">
<SectionHeading
title={$t("about.heading.abuse")}
sectionId="abuse"
/>
we have no way of detecting abusive behavior automatically, as cobalt is 100% anonymous.
however, you can report such activities to us and we will do our best to comply manually: [safety@imput.net](mailto:safety@imput.net)
</section>

View File

@ -7,6 +7,7 @@
"download": "download",
"share": "share",
"copy": "copy",
"copy.section": "copy the section link",
"copied": "copied",
"import": "import",
"continue": "continue",

View File

@ -8,6 +8,8 @@
"tunnel.probe": "couldn't verify whether you can download this file. try again in a few seconds!",
"captcha_ongoing": "still checking if you're not a bot. wait for the spinner to disappear and try again.\n\nif it takes too long, please let us know! we use cloudflare turnstile for bot protection and sometimes it blocks people for no reason.",
"api.auth.jwt.missing": "couldn't confirm whether you're not a robot because the processing server didn't receive the human access token. try again in a few seconds or reload the page!",
"api.auth.jwt.invalid": "couldn't confirm whether you're not a robot because your human access token expired and wasn't renewed. try again in a few seconds or reload the page!",
"api.auth.turnstile.missing": "couldn't confirm whether you're not a robot because the processing server didn't receive the human access token. try again in a few seconds or reload the page!",
@ -45,7 +47,7 @@
"api.content.post.private": "this post is from a private account, so i can't access it. have you pasted the right link?",
"api.content.post.age": "this post is age-restricted, so i can't access it anonymously. have you pasted the right link?",
"api.youtube.codec": "youtube didn't return anything with your preferred codec & resolution. try another set of settings!",
"api.youtube.codec": "youtube didn't return anything with your preferred video codec. try another one in settings!",
"api.youtube.decipher": "youtube updated its decipher algorithm and i couldn't extract the info about the video.\n\ntry again in a few seconds, but if issue sticks, contact us for support.",
"api.youtube.login": "couldn't get this video because youtube labeled me as a bot. this is potentially caused by the processing instance not having any active account tokens. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!",
"api.youtube.token_expired": "couldn't get this video because the youtube token expired and i couldn't refresh it. try again in a few seconds, but if it still doesn't work, tell the instance owner about this error!"

View File

@ -30,9 +30,6 @@
"video.quality.description": "if preferred video quality isn't available, next best is picked instead.",
"video.youtube.codec": "youtube video codec and container",
"video.youtube.codec.h264": "h264 (mp4)",
"video.youtube.codec.av1": "av1 (mp4)",
"video.youtube.codec.vp9": "vp9 (webm)",
"video.youtube.codec.description": "h264: best compatibility, average bitrate. max quality is 1080p. \nav1: best quality, efficiency, and bitrate. supports 8k & HDR. \nvp9: same quality & bitrate as av1, but file is approximately two times bigger. supports 4k & HDR.\n\nav1 and vp9 aren't as widely supported as h264.",
"video.twitter.gif": "twitter/x",
@ -68,7 +65,7 @@
"metadata.filename.basic": "basic",
"metadata.filename.pretty": "pretty",
"metadata.filename.nerdy": "nerdy",
"metadata.filename.description": "filename style will only be used for files tunnelled by cobalt. some services don't support filename styles other than classic.",
"metadata.filename.description": "filename style will only be used for files tunneled by cobalt. some services don't support filename styles other than classic.",
"metadata.filename.preview.video": "Video Title",
"metadata.filename.preview.audio": "Audio Title - Audio Author",
@ -91,17 +88,17 @@
"accessibility.motion.description": "disables animations and transitions whenever possible.",
"language": "language",
"language.auto.title": "use default browser language",
"language.auto.description": "automatically picks the best language for you. if preferred browser language isn't available, english is used instead.",
"language.auto.title": "automatic selection",
"language.auto.description": "cobalt will use your browser's default language if translation is available. if not, english will be used instead.",
"language.preferred.title": "preferred language",
"language.preferred.description": "if any text isnt translated to the preferred language, it will fall back to english.",
"language.preferred.description": "this language will be used when automatic selection is disabled. any text that isn't translated will be displayed in english.\n\nwe use community-sourced translations for languages other than english, russian, and czech. they may be inaccurate or incomplete.",
"privacy.analytics": "anonymous traffic analytics",
"privacy.analytics.title": "don't contribute to analytics",
"privacy.analytics.description": "anonymous traffic analytics are needed to get an approximate number of active cobalt users. no identifiable information about you is ever stored. all processed data is anonymized and aggregated.\n\nwe use a self-hosted plausible instance that doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.",
"privacy.analytics.learnmore": "learn more about plausible's dedication to privacy.",
"privacy.tunnel": "tunnelling",
"privacy.tunnel": "tunneling",
"privacy.tunnel.title": "always tunnel files",
"privacy.tunnel.description": "cobalt will hide your ip address, browser info, and bypass local network restrictions. when enabled, files will also have readable filenames that otherwise would be gibberish.",

View File

@ -1,6 +1,6 @@
{
"name": "@imput/cobalt-web",
"version": "10.0.0",
"version": "10.1.0",
"type": "module",
"private": true,
"scripts": {
@ -25,35 +25,34 @@
"homepage": "https://cobalt.tools/",
"devDependencies": {
"@eslint/js": "^9.5.0",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@fontsource/ibm-plex-mono": "^5.0.13",
"@fontsource/redaction-10": "^5.0.2",
"@imput/libav.js-remux-cli": "^5.5.6",
"@imput/version-info": "workspace:^",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tabler/icons-svelte": "3.6.0",
"@types/eslint__js": "^8.42.3",
"@types/fluent-ffmpeg": "^2.1.25",
"@types/node": "^20.14.10",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"compare-versions": "^6.1.0",
"dotenv": "^16.0.1",
"eslint": "^8.57.0",
"glob": "^10.4.5",
"mdsvex": "^0.11.2",
"mime": "^4.0.4",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"svelte-preprocess": "^6.0.2",
"sveltekit-i18n": "^2.4.2",
"ts-deepmerge": "^7.0.1",
"tslib": "^2.4.1",
"turnstile-types": "^1.2.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.13.1",
"vite": "^5.0.3"
},
"dependencies": {
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@fontsource/ibm-plex-mono": "^5.0.13",
"@imput/libav.js-remux-cli": "^5.5.6",
"@imput/version-info": "workspace:^",
"@tabler/icons-svelte": "3.6.0",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"mime": "^4.0.4",
"sveltekit-i18n": "^2.4.2",
"ts-deepmerge": "^7.0.0"
}
}

View File

@ -14,6 +14,7 @@
export let number: number;
let imageLoaded = false;
const isTunnel = new URL(item.url).pathname === "/tunnel";
$: itemType = item.type ?? "photo";
</script>
@ -23,6 +24,7 @@
on:click={() =>
downloadFile({
url: item.url,
urlType: isTunnel ? "tunnel" : "redirect",
})}
>
<div class="picker-type">

View File

@ -10,6 +10,8 @@
shareFile,
} from "$lib/download";
import type { CobaltFileUrlType } from "$lib/types/api";
import DialogContainer from "$components/dialog/DialogContainer.svelte";
import Meowbalt from "$components/misc/Meowbalt.svelte";
@ -22,12 +24,14 @@
import IconFileDownload from "@tabler/icons-svelte/IconFileDownload.svelte";
import CopyIcon from "$components/misc/CopyIcon.svelte";
export let id: string;
export let dismissable = true;
export let bodyText: string = "";
export let url: string = "";
export let file: File | undefined = undefined;
export let urlType: CobaltFileUrlType | undefined = undefined;
let close: () => void;
@ -55,7 +59,7 @@
</div>
<div class="action-buttons">
{#if device.supports.directDownload}
{#if device.supports.directDownload && !(device.is.iOS && urlType === "redirect")}
<VerticalActionButton
id="save-download"
fill

View File

@ -5,8 +5,6 @@
import Imput from "$components/icons/Imput.svelte";
import Meowbalt from "$components/misc/Meowbalt.svelte";
import IconHeart from "@tabler/icons-svelte/IconHeart.svelte";
</script>
<header id="banner">

View File

@ -107,11 +107,13 @@
</div>
<div id="donation-options">
{#each Object.entries(PRESET_DONATION_AMOUNTS) as [ amount, component ]}
<OuterLink href={donationMethods[processor](+amount * 100)}>
<DonationOption price={+amount} desc={$t(`donate.card.option.${amount}`)}>
<DonationOption
price={+amount}
desc={$t(`donate.card.option.${amount}`)}
href={donationMethods[processor](+amount * 100)}
>
<svelte:component this={component} />
</DonationOption>
</OuterLink>
{/each}
</div>
<div id="donation-custom">

View File

@ -59,7 +59,7 @@
<div class="action-button-icon">
<CopyIcon check={copied} />
</div>
copy
{$t("button.copy")}
</button>
{#if device.supports.share}

View File

@ -1,15 +1,21 @@
<script lang="ts">
export let price: number;
export let desc: string;
export let href: string;
const USD = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
const USD = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
});
</script>
<button class="donation-option">
<button
class="donation-option"
on:click={() => {
window.open(href, "_blank");
}}
>
<div class="donate-card-title">
<slot></slot>
{USD.format(price)}

View File

@ -0,0 +1,9 @@
<!-- Workaround for https://github.com/pngwn/MDsveX/issues/116 -->
<script lang="ts" context="module">
import a from "$components/misc/OuterLink.svelte";
export { a };
</script>
<div class="long-text-noto about">
<slot></slot>
</div>

View File

@ -7,6 +7,7 @@
// i spent 4 hours switching between simulators and devices to get the best way to do this
$: safeAreaTop = 0;
$: safeAreaBottom = 0;
$: state = "hidden"; // "notch", "island", "notch x"
const islandValues = [
@ -24,8 +25,16 @@
.trim();
};
const getSafeAreaBottom = () => {
const root = document.documentElement;
return getComputedStyle(root)
.getPropertyValue("--safe-area-inset-bottom")
.trim();
};
onMount(() => {
safeAreaTop = Number(getSafeAreaTop().replace("px", ""));
safeAreaBottom = Number(getSafeAreaBottom().replace("px", ""));
});
$: if (safeAreaTop > 20) {
@ -36,6 +45,10 @@
if (xNotch.includes(safeAreaTop)) {
state = "notch x";
}
// exception for XR and 11 at regular screen zoom
if (safeAreaTop === 48 && safeAreaBottom === 34) {
state = "notch";
}
}
</script>
@ -84,8 +97,12 @@
}
}
/* plus iphone size, dynamic island, larger text display mode */
/* regular & plus iphone size, dynamic island, larger text display mode */
@media screen and (max-width: 375px) {
#cobalt-notch-sticker.island :global(svg) {
height: 26px;
}
#cobalt-notch-sticker.island {
padding-top: 11px;
}

View File

@ -0,0 +1,91 @@
<script lang="ts">
import { page } from "$app/stores";
import { t } from "$lib/i18n/translations";
import { copyURL } from "$lib/download";
import CopyIcon from "$components/misc/CopyIcon.svelte";
export let title: string;
export let sectionId: string;
export let beta = false;
let copied = false;
$: if (copied) {
setTimeout(() => {
copied = false;
}, 1500);
}
</script>
<div class="heading-container">
<h3 class="content-title">{title}</h3>
{#if beta}
<div class="beta-label">{$t("general.beta")}</div>
{/if}
<button
class="link-copy"
aria-label={copied ? $t("button.copied") : $t("button.copy.section")}
on:click={() => {
copied = true;
copyURL(`${$page.url.origin}${$page.url.pathname}#${sectionId}`);
}}
>
<CopyIcon check={copied} />
</button>
</div>
<style>
.heading-container {
display: flex;
flex-direction: row;
flex-wrap: wrap-reverse;
gap: 6px;
justify-content: start;
align-items: center;
box-shadow: none;
}
.link-copy {
background: transparent;
padding: 2px;
box-shadow: none;
border-radius: 5px;
transition: opacity 0.2s;
opacity: 0.7;
}
.link-copy:focus-visible {
opacity: 1;
}
.link-copy :global(.copy-animation) {
width: 17px;
height: 17px;
}
.link-copy :global(.copy-animation *) {
width: 17px;
height: 17px;
}
.beta-label {
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
padding: 0 5px;
background: var(--secondary);
color: var(--primary);
font-size: 11px;
font-weight: 500;
line-height: 1.9;
text-transform: uppercase;
}
@media (hover: hover) {
.heading-container:hover .link-copy {
opacity: 1;
}
}
</style>

View File

@ -1,14 +1,14 @@
<script lang="ts">
import env from "$lib/env";
import { onMount } from "svelte";
import { cachedInfo } from "$lib/api/server-info";
import { turnstileLoaded, turnstileCreated } from "$lib/state/turnstile";
let turnstileElement: HTMLElement;
let turnstileScript: HTMLElement;
onMount(() => {
const sitekey = env.TURNSTILE_KEY;
const sitekey = $cachedInfo?.info?.cobalt?.turnstileSitekey;
if (!sitekey) return;
$turnstileCreated = true;
@ -17,7 +17,7 @@
window.turnstile?.render(turnstileElement, {
sitekey,
"error-callback": (error) => {
console.log("turnstile error code:", error);
console.log("error code from turnstile:", error);
return true;
},
callback: () => {

View File

@ -4,12 +4,12 @@
import { browser } from "$app/environment";
import { SvelteComponent, tick } from "svelte";
import env from "$lib/env";
import { t } from "$lib/i18n/translations";
import dialogs from "$lib/dialogs";
import { link } from "$lib/state/omnibox";
import { cachedInfo } from "$lib/api/server-info";
import { updateSetting } from "$lib/state/settings";
import { turnstileLoaded } from "$lib/state/turnstile";
@ -35,7 +35,10 @@
let downloadButton: SvelteComponent;
let isFocused = false;
let isDisabled = false;
let isLoading = false;
let isBotCheckOngoing = false;
const validLink = (url: string) => {
try {
@ -57,16 +60,18 @@
goto("/", { replaceState: true });
}
$: if (env.TURNSTILE_KEY) {
$: if ($cachedInfo?.info?.cobalt?.turnstileSitekey) {
if ($turnstileLoaded) {
isDisabled = false;
isBotCheckOngoing = false;
} else {
isDisabled = true;
isBotCheckOngoing = true;
}
} else {
isBotCheckOngoing = false;
}
const pasteClipboard = () => {
if (isDisabled || $dialogs.length > 0) {
if ($dialogs.length > 0 || isDisabled || isLoading) {
return;
}
@ -75,9 +80,11 @@
if (matchLink) {
$link = matchLink[0];
if (!isBotCheckOngoing) {
await tick(); // wait for button to render
downloadButton.download($link);
}
}
});
};
@ -86,7 +93,7 @@
};
const handleKeydown = (e: KeyboardEvent) => {
if (!linkInput || $dialogs.length > 0 || isDisabled) {
if (!linkInput || $dialogs.length > 0 || isDisabled || isLoading) {
return;
}
@ -133,8 +140,11 @@
class:focused={isFocused}
class:downloadable={validLink($link)}
>
<div id="input-link-icon" class:loading={isDisabled}>
{#if isDisabled}
<div
id="input-link-icon"
class:loading={isLoading || isBotCheckOngoing}
>
{#if isLoading || isBotCheckOngoing}
<IconLoader2 />
{:else}
<IconLink />
@ -153,12 +163,14 @@
autocapitalize="off"
maxlength="512"
placeholder={$t("save.input.placeholder")}
aria-label={$t("a11y.save.link_area")}
aria-label={isBotCheckOngoing
? $t("a11y.save.link_area.turnstile")
: $t("a11y.save.link_area")}
data-form-type="other"
disabled={isDisabled}
/>
{#if $link}
{#if $link && !isLoading}
<ClearButton click={() => ($link = "")} />
{/if}
{#if validLink($link)}
@ -166,6 +178,7 @@
url={$link}
bind:this={downloadButton}
bind:disabled={isDisabled}
bind:loading={isLoading}
/>
{/if}
</div>

View File

@ -10,6 +10,7 @@
export let url: string;
export let disabled = false;
export let loading = false;
$: buttonText = ">>";
$: buttonAltText = $t("a11y.save.download");
@ -31,6 +32,7 @@
const changeDownloadButton = (state: DownloadButtonState) => {
disabled = state !== "idle";
loading = state === "think" || state === "check";
buttonText = {
idle: ">>",
@ -86,6 +88,7 @@
return downloadFile({
url: response.url,
urlType: "redirect",
});
}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import locale from "$lib/i18n/locale";
import languages from "$i18n/languages.json";
import { t, locales } from "$lib/i18n/translations";
@ -10,10 +9,12 @@
$: currentSetting = $settings.appearance.language;
$: disabled = $settings.appearance.autoLanguage;
const updateLocale = (lang: string) => {
const updateLocale = (event: Event) => {
const target = event.target as HTMLSelectElement;
updateSetting({
appearance: {
language: lang as keyof typeof languages,
language: target.value as keyof typeof languages,
},
});
};
@ -34,8 +35,7 @@
</div>
<select
id="setting-dropdown-appearance-language"
bind:value={$locale}
on:change={() => updateLocale($locale)}
on:change={updateLocale}
{disabled}
>
{#each $locales as value}

View File

@ -122,5 +122,6 @@
.button-row {
display: flex;
gap: var(--padding);
flex-wrap: wrap;
}
</style>

View File

@ -1,6 +1,8 @@
<script lang="ts">
import { page } from "$app/stores";
import { t } from "$lib/i18n/translations";
import { copyURL as _copyURL } from "$lib/download";
import SectionHeading from "$components/misc/SectionHeading.svelte";
export let title: string;
export let sectionId: string;
@ -9,12 +11,19 @@
export let beta = false;
let focus = false;
let copied = false;
$: hash = $page.url.hash.replace("#", "");
$: if (hash === sectionId) {
focus = true;
}
$: if (copied) {
setTimeout(() => {
copied = false;
}, 1500);
}
</script>
<section
@ -24,12 +33,7 @@
class:disabled
aria-hidden={disabled}
>
<div class="settings-content-header">
<h3 class="settings-content-title">{title}</h3>
{#if beta}
<div class="beta-label">{$t("general.beta")}</div>
{/if}
</div>
<SectionHeading {title} {sectionId} {beta} />
<slot></slot>
</section>
@ -82,27 +86,6 @@
}
}
.settings-content-header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.beta-label {
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
padding: 0 5px;
background: var(--secondary);
color: var(--primary);
font-size: 11px;
font-weight: 500;
line-height: 0;
text-transform: uppercase;
}
@media screen and (max-width: 750px) {
.settings-content {
padding: var(--padding);

View File

@ -70,7 +70,6 @@
#sidebar-tabs {
height: 100%;
width: var(--sidebar-width);
justify-content: space-between;
padding: var(--sidebar-inner-padding);
padding-bottom: var(--border-radius);
@ -110,7 +109,6 @@
overflow-x: scroll;
padding-bottom: 0;
padding: var(--sidebar-inner-padding) 0;
width: unset;
height: fit-content;
}

View File

@ -28,11 +28,6 @@
$: if (isTabActive && tab) {
showTab(tab);
tab.classList.add("animate");
setTimeout(() => {
tab.classList.remove("animate");
}, 250);
}
</script>
@ -59,11 +54,11 @@
flex-direction: column;
align-items: center;
text-align: center;
gap: 5px;
padding: var(--padding) 5px;
gap: 3px;
padding: var(--padding) 3px;
color: var(--sidebar-highlight);
font-size: var(--sidebar-font-size);
opacity: 0.8;
opacity: 0.75;
height: fit-content;
border-radius: var(--border-radius);
transition: transform 0.2s;
@ -72,12 +67,14 @@
text-decoration-line: none;
position: relative;
scroll-behavior: smooth;
cursor: pointer;
}
.sidebar-tab :global(svg) {
stroke-width: 1.2px;
height: 21px;
width: 21px;
height: 22px;
width: 22px;
}
:global([data-iphone="true"] .sidebar-tab svg) {
@ -88,19 +85,17 @@
color: var(--sidebar-bg);
background: var(--sidebar-highlight);
opacity: 1;
transition: none;
transform: none;
transition: none;
animation: pressButton 0.3s;
cursor: default;
}
:global(.sidebar-tab.animate) {
animation: pressButton 0.2s;
}
.sidebar-tab:active:not(.active) {
.sidebar-tab:not(.active):active {
transform: scale(0.95);
}
:global([data-reduce-motion="true"]) .sidebar-tab:active:not(.active) {
:global([data-reduce-motion="true"]) .sidebar-tab:active {
transform: none;
}
@ -112,10 +107,10 @@
@keyframes pressButton {
0% {
transform: scale(0.95);
transform: scale(0.9);
}
50% {
transform: scale(1.02);
transform: scale(1.015);
}
100% {
transform: scale(1);
@ -127,6 +122,7 @@
opacity: 1;
background-color: var(--sidebar-hover);
}
.sidebar-tab:hover:not(.active) {
opacity: 1;
background-color: var(--sidebar-hover);
@ -149,10 +145,10 @@
@keyframes pressButton {
0% {
transform: scale(0.9);
transform: scale(0.8);
}
60% {
transform: scale(1.015);
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);

View File

@ -73,7 +73,10 @@
</h3>
{:else}
{#if pageSubtitle}
<div class="subtext subnav-subtitle">
<div
class="subtext subnav-subtitle"
class:hidden={pageSubtitle === "\xa0"}
>
{pageSubtitle}
</div>
{/if}
@ -89,7 +92,10 @@
>
<slot name="navigation"></slot>
{#if isMobile && isHome && pageSubtitle}
<div class="subtext subnav-subtitle center">
<div
class="subtext subnav-subtitle center"
class:hidden={pageSubtitle === "\xa0"}
>
{pageSubtitle}
</div>
{/if}
@ -132,7 +138,7 @@
}
.subnav-page-content.wide {
max-width: 800px;
max-width: 700px;
}
.subnav-page-content.padding {
@ -170,6 +176,11 @@
.subnav-subtitle {
padding: 0;
transition: opacity 0.1s;
}
.subnav-subtitle.hidden {
opacity: 0;
}
.subnav-subtitle.center {

View File

@ -47,6 +47,8 @@
text-decoration: none;
text-decoration-line: none;
cursor: pointer;
}
.subnav-tab-left {
@ -93,6 +95,7 @@
.subnav-tab.active {
background: var(--secondary);
color: var(--primary);
cursor: default;
}
.subnav-tab-text {

View File

@ -1,12 +1,16 @@
import { get } from "svelte/store";
import settings from "$lib/state/settings";
import lazySettingGetter from "$lib/settings/lazy-get";
import { getSession } from "$lib/api/session";
import { currentApiURL } from "$lib/api/api-url";
import { turnstileLoaded } from "$lib/state/turnstile";
import { apiOverrideWarning } from "$lib/api/safety-warning";
import { cachedInfo, getServerInfo } from "$lib/api/server-info";
import type { Optional } from "$lib/types/generic";
import type { CobaltAPIResponse, CobaltErrorResponse } from "$lib/types/api";
import lazySettingGetter from "$lib/settings/lazy-get";
const request = async (url: string) => {
const getSetting = lazySettingGetter(get(settings));
@ -34,11 +38,32 @@ const request = async (url: string) => {
await apiOverrideWarning();
const usingCustomInstance = getSetting("processing", "enableCustomInstances")
&& getSetting("processing", "customInstanceURL");
await getServerInfo();
const getCachedInfo = get(cachedInfo);
if (!getCachedInfo) {
return {
status: "error",
error: {
code: "error.api.unreachable"
}
} as CobaltErrorResponse;
}
if (getCachedInfo?.info?.cobalt?.turnstileSitekey && !get(turnstileLoaded)) {
return {
status: "error",
error: {
code: "error.captcha_ongoing"
}
} as CobaltErrorResponse;
}
const api = currentApiURL();
// FIXME: rewrite this to allow custom instances to specify their own turnstile tokens
const session = usingCustomInstance ? undefined : await getSession();
const session = getCachedInfo?.info?.cobalt?.turnstileSitekey
? await getSession() : undefined;
let extraHeaders = {}

View File

@ -1,3 +1,5 @@
import { browser } from "$app/environment";
import { get, writable } from "svelte/store";
import { currentApiURL } from "$lib/api/api-url";
@ -50,6 +52,17 @@ export const getServerInfo = async () => {
info: freshInfo,
origin: currentApiURL(),
});
/*
reload the page if turnstile sitekey changed.
there's no other proper way to do this, at least i couldn't find any :(
*/
if (cache?.info?.cobalt?.turnstileSitekey && freshInfo?.cobalt?.turnstileSitekey) {
if (browser) {
window.location.reload();
}
}
return true;
}

View File

@ -4,16 +4,31 @@ import settings from "$lib/state/settings";
import { device } from "$lib/device";
import { t } from "$lib/i18n/translations";
import { createDialog } from "$lib/dialogs";
import type { DialogInfo } from "$lib/types/dialog";
const openSavingDialog = ({ url, file, body }: { url?: string, file?: File, body?: string }) => {
import type { DialogInfo } from "$lib/types/dialog";
import type { CobaltFileUrlType } from "$lib/types/api";
type DownloadFileParams = {
url?: string,
file?: File,
urlType?: CobaltFileUrlType,
}
type SavingDialogParams = {
url?: string,
file?: File,
body?: string,
urlType?: CobaltFileUrlType,
}
const openSavingDialog = ({ url, file, body, urlType }: SavingDialogParams) => {
const dialogData: DialogInfo = {
type: "saving",
id: "saving",
file,
url,
urlType,
}
if (body) dialogData.bodyText = body;
@ -60,13 +75,13 @@ export const copyURL = async (url: string) => {
return await navigator?.clipboard?.writeText(url);
}
export const downloadFile = ({ url, file }: { url?: string, file?: File }) => {
export const downloadFile = ({ url, file, urlType }: DownloadFileParams) => {
if (!url && !file) throw new Error("attempted to download void");
const pref = get(settings).save.savingMethod;
if (pref === "ask") {
return openSavingDialog({ url, file });
return openSavingDialog({ url, file, urlType });
}
/*
@ -77,12 +92,15 @@ export const downloadFile = ({ url, file }: { url?: string, file?: File }) => {
navigator.userActivation.isActive makes sure that we're still able to
invoke an action without the user agent interrupting it.
if not, we show a saving dialog for user to re-invoke that action.
if browser is old or doesn't support this API, we just assume that it expired.
*/
if (!navigator.userActivation.isActive) {
if (!navigator?.userActivation?.isActive) {
return openSavingDialog({
url,
file,
body: get(t)("dialog.saving.timeout"),
urlType
});
}
@ -98,7 +116,8 @@ export const downloadFile = ({ url, file }: { url?: string, file?: File }) => {
if (url) {
if (pref === "share" && device.supports.share) {
return shareURL(url);
} else if (pref === "download" && device.supports.directDownload) {
} else if (pref === "download" && device.supports.directDownload
&& !(device.is.iOS && urlType === "redirect")) {
return openURL(url);
} else if (pref === "copy" && !file) {
return copyURL(url);
@ -106,5 +125,5 @@ export const downloadFile = ({ url, file }: { url?: string, file?: File }) => {
}
} catch { /* catch & ignore */ }
return openSavingDialog({ url, file });
return openSavingDialog({ url, file, urlType });
}

View File

@ -14,7 +14,6 @@ const variables = {
PLAUSIBLE_HOST: getEnv('PLAUSIBLE_HOST'),
PLAUSIBLE_ENABLED: getEnv('HOST') && getEnv('PLAUSIBLE_HOST'),
DEFAULT_API: getEnv('DEFAULT_API'),
TURNSTILE_KEY: getEnv('TURNSTILE_KEY'),
}
const contacts = {

View File

@ -7,7 +7,7 @@ import type {
LocalizationContent
} from '$lib/types/i18n';
import languages from '$i18n/languages.json';
import _languages from '$i18n/languages.json';
const locFiles = import.meta.glob('$i18n/*/**/*.json');
const parsedLocfiles: StructuredLocfileInfo = {};
@ -22,6 +22,8 @@ for (const [path, loader] of Object.entries(locFiles)) {
}
const defaultLocale = 'en';
const languages: Record<string, string> = _languages;
const config: Config<{
value?: string;
formats?: string;
@ -30,6 +32,8 @@ const config: Config<{
}> = {
fallbackLocale: defaultLocale,
translations: Object.keys(parsedLocfiles).reduce((obj, lang) => {
languages[lang] ??= `${lang} (missing name)`;
return {
...obj,
[lang]: { languages }

2
web/src/lib/polyfills.ts Normal file
View File

@ -0,0 +1,2 @@
import "./polyfills/user-activation";
import "./polyfills/abortsignal-timeout";

View File

@ -0,0 +1,10 @@
import { browser } from "$app/environment";
if (browser && 'AbortSignal' in window && !window.AbortSignal.timeout) {
window.AbortSignal.timeout = (milliseconds: number) => {
const controller = new AbortController();
setTimeout(() => controller.abort("timed out"), milliseconds);
return controller.signal;
}
}

View File

@ -0,0 +1,54 @@
import { browser } from "$app/environment";
import type { Writeable } from "$lib/types/generic";
if (browser && !navigator.userActivation) {
const TRANSIENT_TIMEOUT = navigator.userAgent.includes('Firefox') ? 5000 : 2000;
let _timeout: number | undefined;
const userActivation: Writeable<UserActivation> = {
isActive: false,
hasBeenActive: false
};
const receiveEvent = (e: Event) => {
// An activation triggering input event is any event whose isTrusted attribute is true [...]
if (!e.isTrusted) return;
// and whose type is one of:
if (e instanceof PointerEvent) {
if (
// "pointerdown", provided the event's pointerType is "mouse";
(e.type === 'pointerdown' && e.pointerType !== 'mouse')
// "pointerup", provided the event's pointerType is not "mouse";
|| (e.type === 'pointerup' && e.pointerType === 'mouse')
)
return;
} else if (e instanceof KeyboardEvent) {
// "keydown", provided the key is neither the Esc key nor a shortcut key
// reserved by the user agent;
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey)
return;
// the handling for this is a bit more complex,
// but this is fine for our use case
if (e.key !== 'Return' && e.key !== 'Enter' && e.key.length > 1)
return;
}
userActivation.hasBeenActive = true;
userActivation.isActive = true;
clearTimeout(_timeout);
_timeout = window.setTimeout(() => {
userActivation.isActive = false;
_timeout = undefined;
}, TRANSIENT_TIMEOUT);
}
// https://html.spec.whatwg.org/multipage/interaction.html#the-useractivation-interface
for (const event of [ 'keydown', 'mousedown', 'pointerdown', 'pointerup', 'touchend' ]) {
window.addEventListener(event, receiveEvent);
}
(navigator.userActivation as UserActivation) = userActivation;
}

View File

@ -40,6 +40,8 @@ type CobaltTunnelResponse = {
status: CobaltResponseType.Tunnel,
} & CobaltPartialURLResponse;
export type CobaltFileUrlType = "redirect" | "tunnel";
export type CobaltSession = {
token: string,
exp: number,
@ -51,6 +53,7 @@ export type CobaltServerInfo = {
url: string,
startTime: string,
durationLimit: number,
turnstileSitekey?: string,
services: string[]
},
git: {

View File

@ -1,3 +1,4 @@
import type { CobaltFileUrlType } from "$lib/types/api";
import type { MeowbaltEmotions } from "$lib/types/meowbalt";
export type DialogButton = {
@ -43,6 +44,7 @@ type SavingDialog = Dialog & {
bodyText?: string,
url?: string,
file?: File,
urlType?: CobaltFileUrlType,
};
export type DialogInfo = SmallDialog | PickerDialog | SavingDialog;

View File

@ -9,3 +9,4 @@ export type RecursivePartial<Type> = {
export type DefaultImport<T> = () => Promise<{ default: T }>;
export type Optional<T> = T | undefined;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };

View File

@ -7,7 +7,9 @@
import { updated } from "$app/stores";
import { browser } from "$app/environment";
import { afterNavigate } from "$app/navigation";
import { getServerInfo, cachedInfo } from "$lib/api/server-info";
import "$lib/polyfills";
import env from "$lib/env";
import settings from "$lib/state/settings";
import locale from "$lib/i18n/locale";
@ -30,10 +32,16 @@
$settings.appearance.reduceTransparency ||
device.prefers.reducedTransparency;
afterNavigate(() => {
$: spawnTurnstile = !!$cachedInfo?.info?.cobalt?.turnstileSitekey;
afterNavigate(async() => {
const to_focus: HTMLElement | null =
document.querySelector("[data-first-focus]");
to_focus?.focus();
if ($page.url.pathname === "/") {
await getServerInfo();
}
});
</script>
@ -76,7 +84,7 @@
<DialogHolder />
<Sidebar />
<div id="content">
{#if (env.TURNSTILE_KEY && $page.url.pathname === "/") || $turnstileCreated}
{#if (spawnTurnstile && $page.url.pathname === "/") || $turnstileCreated}
<Turnstile />
{/if}
<slot></slot>
@ -135,6 +143,7 @@
);
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--switcher-padding: var(--sidebar-inner-padding);
@ -483,6 +492,14 @@
margin-block-start: 0.3em;
}
:global(.long-text-noto.about .heading-container) {
padding-top: calc(var(--padding) / 2);
}
:global(.long-text-noto.about section:first-of-type .heading-container) {
padding-top: 0;
}
@media screen and (max-width: 535px) {
:global(.long-text-noto),
:global(.long-text-noto *:not(h1, h2, h3, h4, h5, h6)) {

View File

@ -0,0 +1,21 @@
export function GET() {
const _headers = {
"/*": {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
}
return new Response(
Object.entries(_headers).map(
([path, headers]) => [
path,
Object.entries(headers).map(
([key, value]) => ` ${key}: ${value}`
)
].flat().join("\n")
).join("\n\n")
);
}
export const prerender = true;

View File

@ -0,0 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:component this={data.component} />

View File

@ -0,0 +1,29 @@
import type { ComponentType, SvelteComponent } from 'svelte';
import { get } from 'svelte/store';
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import locale from '$lib/i18n/locale';
import type { DefaultImport } from '$lib/types/generic';
import { defaultLocale } from '$lib/i18n/translations';
const pages = import.meta.glob('$i18n/*/about/*.md');
export const load: PageLoad = async ({ params }) => {
const getPage = (locale: string) => Object.keys(pages).find(
file => file.endsWith(`${locale}/about/${params.page}.md`)
);
const componentPath = getPage(get(locale)) || getPage(defaultLocale);
if (componentPath) {
type Component = ComponentType<SvelteComponent>;
const componentImport = pages[componentPath] as DefaultImport<Component>;
return { component: (await componentImport()).default }
}
error(404, 'Not found');
};
export const prerender = true;

View File

@ -1,41 +0,0 @@
<script lang="ts">
import { contacts, docs } from "$lib/env";
import OuterLink from "$components/misc/OuterLink.svelte";
</script>
<div id="credits-body" class="long-text-noto about">
<section id="meowbalt">
<h3>meowbalt</h3>
<p>
meowbalt is cobalt's speedy mascot. he is an extremely expressive cat that loves fast internet.
</p>
<p>
all amazing drawings of meowbalt that you see in cobalt were made by
<OuterLink href="https://glitchypsi.xyz/">GlitchyPSI</OuterLink>.
he is also the original designer of the character.
</p>
<p>
you cannot use or modify GlitchyPSI's artworks of meowbalt without his explicit permission.
</p>
<p>
you cannot use or modify the meowbalt character design commercially or in any form that isn't fan art.
</p>
</section>
<section id="licenses">
<h3>cobalt licenses</h3>
<p>
cobalt processing server is open source and licensed under <OuterLink href={docs.apiLicense}>AGPL-3.0</OuterLink>.
</p>
<p>
cobalt frontend is
<OuterLink href="https://sourcefirst.com/">source first</OuterLink>
and licensed under
<OuterLink href={docs.webLicense}>CC-BY-NC-SA 4.0</OuterLink>.
we decided to use this license to stop grifters from profiting off our work & from creating malicious clones that deceive people and hurt our public identity.
</p>
<p>
we rely on many open source libraries, create & distribute our own.
you can see the full list of dependencies on <OuterLink href={contacts.github}>github</OuterLink>.
</p>
</section>
</div>

View File

@ -1,87 +0,0 @@
<script lang="ts">
import { partners, contacts, docs } from "$lib/env";
import OuterLink from "$components/misc/OuterLink.svelte";
</script>
<div id="privacy-body" class="long-text-noto about">
<section id="saving">
<h3>best way to save what you love</h3>
<p>
cobalt lets you save anything from your favorite websites: video, audio, photos or gifs — cobalt can do it all!
</p>
<p>
no ads, trackers, or paywalls, no nonsense. just a convenient web app that works everywhere.
</p>
</section>
<section id="privacy">
<h3>leading privacy</h3>
<p>
all requests to backend are anonymous and all tunnels are encrypted.
we have a strict zero log policy and don't track <i>anything at all</i>.
</p>
<p>
to avoid caching or storing downloaded files, cobalt processes them on-fly, sending processed pieces directly to client.
this technology is used when your request needs additional processing, such as when source service stores video & audio in separate files.
</p>
<p>
for even higher level of protection, you can <a href="/settings/privacy#tunnel">ask cobalt to always tunnel everything</a>.
when enabled, cobalt will proxy everything through itself. no one will know what you download, even your network provider/admin.
all they'll see is that you're using cobalt.
</p>
</section>
<section id="speed">
<h3>blazing speed</h3>
<p>
since we don't rely on any existing downloaders and develop our own from ground up,
cobalt is extremely efficient and a processing server can run on basically any hardware.
</p>
<p>
main processing instances are hosted on several dedicated servers in several countries,
to reduce latency and distribute the traffic.
</p>
<p>
we constantly improve our infrastructure along with our long-standing partner,
<OuterLink href="{partners.royalehosting}">royalehosting.net</OuterLink>!
you're in good hands, and will get what you need within seconds.
</p>
</section>
<section id="community">
<h3>open community</h3>
<p>
cobalt is used by countless artists, educators, and content creators to do what they love.
we're always on the line with our community and work together to create even more useful tools for them.
feel free to <a href="/about/community">join the conversation</a>!
</p>
<p>
we believe that the future of the internet is open,
which is why cobalt is
<OuterLink
href="https://sourcefirst.com/">
source first
</OuterLink>
and
<OuterLink href={docs.instanceHosting}>
easily self-hostable.
</OuterLink>
you can <OuterLink href="{contacts.github}">check the source code & contribute to cobalt</OuterLink>
at any time, we welcome all contributions and suggestions.
</p>
<p>
you can use any processing instances hosted by the community, including your own.
if your friend hosts one, just ask them for a domain and <a href="/settings/instances#community">add it in instance settings</a>.
</p>
</section>
<section id="local">
<h3>on-device processing</h3>
<p>
new features, such as <a href="/remux">remuxing</a>, work on-device.
on-device processing is efficient and never sends anything over the internet.
it perfectly aligns with our future goal of moving as much processing as possible to client.
</p>
</section>
</div>

View File

@ -1,81 +0,0 @@
<script lang="ts">
import env from "$lib/env";
import { t } from "$lib/i18n/translations";
import OuterLink from "$components/misc/OuterLink.svelte";
</script>
<div id="privacy-body" class="long-text-noto about">
<section id="general">
<h3>general terms</h3>
<p>
cobalt's privacy policy is simple: we don't collect or store anything about you. what you do is solely your business, not ours or anyone else's.
</p>
<p>
these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
</p>
</section>
<section id="local">
<h3>on-device processing</h3>
<p>
tools that use on-device processing work offline, locally, and never send any data anywhere. they are explicitly marked as such whenever applicable.
</p>
</section>
<section id="saving">
<h3>saving</h3>
<p>
when using saving functionality, in some cases cobalt will encrypt & temporarily store information needed for tunnelling. it's stored in processing server's RAM for 90 seconds and irreversibly purged afterwards. no one has access to it, even instance owners, as long as they don't modify the official cobalt image.
</p>
<p>
processed/tunnelled files are never cached anywhere. everything is tunnelled live. cobalt's saving functionality is essentially a fancy proxy service.
</p>
</section>
<section id="encryption">
<h3>encryption</h3>
<p>
temporarily stored tunnel data is encrypted using the AES-256 standard. decryption keys are only included in the access link and never logged/cached/stored anywhere. only the end user has access to the link & encryption keys. keys are generated uniquely for each requested tunnel.
</p>
</section>
{#if env.PLAUSIBLE_ENABLED}
<section id="plausible">
<h3>anonymous traffic analytics</h3>
<p>
for sake of privacy, we use
<OuterLink href="https://plausible.io/"> plausible's anonymous traffic analytics</OuterLink>
to get an approximate number of active cobalt users. no identifiable information about you or your requests is ever stored. all data is anonymized and aggregated. the plausible instance we use is hosted & managed by us.
</p>
<p>
plausible doesn't use cookies and is fully compliant with GDPR, CCPA, and PECR.
</p>
<p>
<OuterLink href="https://plausible.io/privacy-focused-web-analytics">
{$t("settings.privacy.analytics.learnmore")}
</OuterLink>
</p>
<p>
if you wish to opt out of anonymous analytics, you can do it in <a href="/settings/privacy#analytics">privacy settings</a>.
</p>
</section>
{/if}
<section id="cloudflare">
<h3>web privacy & security</h3>
<p>
we use cloudflare services for ddos & bot protection. we also use cloudflare pages for deploying & hosting the static web app. all of these are required to provide the best experience for everyone. it's the most private & reliable provider that we know of.
</p>
<p>
cloudflare is fully compliant with GDPR and HIPAA.
</p>
<p>
<OuterLink href="https://www.cloudflare.com/trust-hub/privacy-and-data-protection/">
learn more about cloudflare's dedication to privacy.
</OuterLink>
</p>
</section>
</div>

View File

@ -1,37 +0,0 @@
<div id="terms-body" class="long-text-noto about">
<section id="saving">
<section id="general">
<h3>general terms</h3>
<p>
these terms are applicable only when using the official cobalt instance. in other cases, you may need to contact the hoster for accurate info.
</p>
</section>
<h3>saving</h3>
<p>
saving functionality simplifies downloading content from the internet and takes zero liability for what the saved content is used for. processing servers work like advanced proxies and don't ever write any content to disk. everything is handled in RAM and permanently purged once the tunnel is done. we have no downloading logs and can't identify anyone.
</p>
<p>
<a href="/about/privacy">you can read more about how tunnels work in our privacy policy.</a>
</p>
</section>
<section id="responsibiliy">
<h3>responsibilities</h3>
<p>
you (end user) are responsible for what you do with our tools, how you use and distribute resulting content. please be mindful when using content of others and always credit original creators. make sure you don't violate any terms or licenses.
</p>
<p>
when used in educational purposes, always cite sources and credit original creators.
</p>
<p>
fair use and credits benefit everyone.
</p>
</section>
<section id="abuse">
<h3>reporting abuse</h3>
<p>
we have no way of detecting abusive behavior automatically, as cobalt is 100% anonymous. however, you can report such activities to us and we will do our best to comply manually: <a href="mailto:safety@imput.net">safety@imput.net</a>
</p>
</section>
</div>

View File

@ -9,7 +9,7 @@
import SettingsToggle from "$components/buttons/SettingsToggle.svelte";
</script>
<SettingsCategory sectionId="audio-format" title={$t("settings.audio.format")}>
<SettingsCategory sectionId="format" title={$t("settings.audio.format")}>
<Switcher big={true} description={$t("settings.audio.format.description")}>
{#each audioFormatOptions as value}
<SettingsButton
@ -23,8 +23,7 @@
</Switcher>
</SettingsCategory>
<SettingsCategory sectionId="audio-bitrate" title={$t("settings.audio.bitrate")}>
<SettingsCategory sectionId="bitrate" title={$t("settings.audio.bitrate")}>
<Switcher big={true} description={$t("settings.audio.bitrate.description")}>
{#each audioBitrateOptions as value}
<SettingsButton

View File

@ -45,7 +45,7 @@
</SettingsCategory>
<SettingsCategory
sectionId="disable-metadata"
sectionId="metadata"
title={$t("settings.metadata.file")}
>
<SettingsToggle

View File

@ -8,10 +8,16 @@
import Switcher from "$components/buttons/Switcher.svelte";
import SettingsButton from "$components/buttons/SettingsButton.svelte";
import SettingsToggle from "$components/buttons/SettingsToggle.svelte";
const codecTitles = {
h264: "h264 (mp4)",
av1: "av1 (webm)",
vp9: "vp9 (webm)",
}
</script>
<SettingsCategory
sectionId="video-quality"
sectionId="quality"
title={$t("settings.video.quality")}
>
<Switcher big={true} description={$t("settings.video.quality.description")}>
@ -28,7 +34,7 @@
</SettingsCategory>
<SettingsCategory
sectionId="youtube-codec"
sectionId="codec"
title={$t("settings.video.youtube.codec")}
>
<Switcher
@ -41,7 +47,7 @@
settingId="youtubeVideoCodec"
settingValue={value}
>
{$t(`settings.video.youtube.codec.${value}`)}
{codecTitles[value]}
</SettingsButton>
{/each}
</Switcher>

View File

@ -1,3 +0,0 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

View File

@ -1,8 +1,10 @@
import adapter from '@sveltejs/adapter-static';
import { mdsvex } from 'mdsvex';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { sveltePreprocess } from 'svelte-preprocess';
import "dotenv/config";
import adapter from "@sveltejs/adapter-static";
import { mdsvex } from "mdsvex";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { sveltePreprocess } from "svelte-preprocess";
/** @type {import('@sveltejs/kit').Config} */
const config = {
@ -24,10 +26,16 @@ const config = {
sveltePreprocess(),
mdsvex({
extensions: ['.md'],
layout: join(
layout: {
about: join(
dirname(fileURLToPath(import.meta.url)),
'/src/components/misc/AboutPageWrapper.svelte'
),
changelogs: join(
dirname(fileURLToPath(import.meta.url)),
'/src/components/changelog/ChangelogEntryWrapper.svelte'
)
}
})
],
kit: {
@ -40,6 +48,37 @@ const config = {
precompress: false,
strict: true
}),
csp: {
mode: "hash",
directives: {
"connect-src": ["*"],
"default-src": ["none"],
"font-src": ["self"],
"style-src": ["self", "unsafe-inline"],
"img-src": ["*", "data:"],
"manifest-src": ["self"],
"worker-src": ["self"],
"object-src": ["none"],
"frame-src": [
"self",
"challenges.cloudflare.com"
],
"script-src": [
"self",
"wasm-unsafe-eval",
"challenges.cloudflare.com",
// eslint-disable-next-line no-undef
process.env.WEB_PLAUSIBLE_HOST ? process.env.WEB_PLAUSIBLE_HOST : "",
// hash of the theme preloader in app.html
"sha256-g67gIjM3G8yMbjbxyc3QUoVsKhdxgcQzCmSKXiZZo6s=",
]
}
},
env: {
publicPrefix: 'WEB_'
},

View File

@ -43,7 +43,6 @@ const exposeLibAV: PluginOption = (() => {
for (const module of modules) {
const distFolder = join(IMPUT_MODULE_DIR, module, 'dist/');
console.log(distFolder);
await cp(distFolder, assets, { recursive: true });
}
}
@ -72,7 +71,7 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('/web/i18n')) {
if (id.includes('/web/i18n') && id.endsWith('.json')) {
const lang = id.split('/web/i18n/')?.[1].split('/')?.[0];
if (lang) {
return `i18n_${lang}`;