mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8bf295889 | ||
|
|
32583b6bec | ||
|
|
6c3208a7a5 | ||
|
|
46cbb3be0c | ||
|
|
a2e5fc01d5 | ||
|
|
96e2d95d02 | ||
|
|
07fd4fae5f | ||
|
|
3ca8a911da | ||
|
|
56eeb242de | ||
|
|
68f4b5d86a | ||
|
|
8f387aa9ea | ||
|
|
706d4c1a1e | ||
|
|
39fb75ebf7 | ||
|
|
6c86bb933b | ||
|
|
4bb20e560f | ||
|
|
5302e3a987 | ||
|
|
9b4c84ddd8 | ||
|
|
528a9b76bc |
@@ -210,7 +210,7 @@ function getModelUrlForVariant(variant: string): string {
|
||||
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2'
|
||||
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2-fixed-uvs'
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
|
||||
@@ -807,6 +807,11 @@ export function createContentInstall(opts: {
|
||||
) {
|
||||
const project: Labrinth.Projects.v2.Project = await get_project(projectId, 'must_revalidate')
|
||||
|
||||
if (!project) {
|
||||
opts.handleError(`Project not found: '${projectId}'`)
|
||||
return
|
||||
}
|
||||
|
||||
if (project.project_type === 'modpack') {
|
||||
const version = versionId ?? project.versions[project.versions.length - 1]
|
||||
const packs = await list()
|
||||
|
||||
@@ -582,8 +582,6 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
}
|
||||
})?;
|
||||
|
||||
webview.open_devtools();
|
||||
|
||||
Some(webview)
|
||||
} else {
|
||||
None
|
||||
@@ -701,15 +699,16 @@ pub async fn hide_ads_window<R: Runtime>(
|
||||
reset: Option<bool>,
|
||||
) -> crate::api::Result<()> {
|
||||
let reset = reset.unwrap_or(false);
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if reset {
|
||||
state.shown = false;
|
||||
state.consent_overlay_shown = false;
|
||||
}
|
||||
|
||||
if let Some(webview) = app.webviews().get("ads-window") {
|
||||
let state = app.state::<RwLock<AdsState>>();
|
||||
let mut state = state.write().await;
|
||||
|
||||
if reset {
|
||||
state.shown = false;
|
||||
state.consent_overlay_shown = false;
|
||||
} else {
|
||||
if !reset {
|
||||
state.modal_shown = true;
|
||||
|
||||
if state.consent_overlay_shown {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
@@ -94,6 +95,7 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef')
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const rows = ref<ScanRow[]>([])
|
||||
@@ -217,6 +219,10 @@ async function fetchAllScans() {
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirmClearGroups() {
|
||||
clearModalRef.value?.show()
|
||||
}
|
||||
|
||||
async function clearAllGroups() {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
@@ -270,6 +276,14 @@ defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="clearModalRef"
|
||||
title="Clear all permission groups?"
|
||||
description="This will clear **all** groups for this project. This action cannot be undone."
|
||||
proceed-label="Clear"
|
||||
@proceed="clearAllGroups"
|
||||
/>
|
||||
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
width="60vw"
|
||||
@@ -288,11 +302,11 @@ defineExpose({ show, hide })
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled circular>
|
||||
<ButtonStyled circular color="red" color-fill="none">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.clearAllGroups)"
|
||||
:disabled="isBusy || rows.length === 0"
|
||||
@click="clearAllGroups"
|
||||
@click="showConfirmClearGroups"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
|
||||
LABRINTH_ADMIN_KEY=feedbeef
|
||||
LABRINTH_MEDAL_KEY=
|
||||
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
|
||||
LABRINTH_SUBSCRIPTIONS_KEY=
|
||||
RATE_LIMIT_IGNORE_KEY=feedbeef
|
||||
|
||||
DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
|
||||
|
||||
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
|
||||
LABRINTH_ADMIN_KEY=feedbeef
|
||||
LABRINTH_MEDAL_KEY=
|
||||
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
|
||||
LABRINTH_SUBSCRIPTIONS_KEY=
|
||||
RATE_LIMIT_IGNORE_KEY=feedbeef
|
||||
|
||||
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::database::PgTransaction;
|
||||
use crate::database::models::{
|
||||
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
|
||||
};
|
||||
@@ -131,7 +130,7 @@ impl DBUserSubscription {
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -156,7 +155,7 @@ impl DBUserSubscription {
|
||||
self.status.as_str(),
|
||||
serde_json::to_value(&self.metadata)?,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -26,11 +26,6 @@ use util::{cmd, redis_pipe};
|
||||
|
||||
pub mod util;
|
||||
|
||||
const DEFAULT_EXPIRY: i64 = 60 * 60 * 12; // 12 hours
|
||||
const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes
|
||||
const VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 48; // 48 hours
|
||||
const VERSION_ACTUAL_EXPIRY: i64 = 60 * 60 * 24; // 24 hours
|
||||
|
||||
// Bound how many commands we send in a single Redis pipeline. The multiplexed
|
||||
// connection's BytesMut write buffer keeps its peak capacity for the life of
|
||||
// the connection, so larger pipelines cause higher steady-state RSS.
|
||||
@@ -150,10 +145,11 @@ where
|
||||
fn cache_expiries(namespace: &str) -> (i64, i64) {
|
||||
// Namespaces may embed a version suffix like `:v1`, so split it out.
|
||||
match namespace.split_once(':').map(|t| t.0).unwrap_or(namespace) {
|
||||
"versions" | "versions_files" => {
|
||||
(VERSION_DEFAULT_EXPIRY, VERSION_ACTUAL_EXPIRY)
|
||||
}
|
||||
_ => (DEFAULT_EXPIRY, ACTUAL_EXPIRY),
|
||||
"versions" | "versions_files" => (
|
||||
ENV.REDIS_VERSION_DEFAULT_EXPIRY,
|
||||
ENV.REDIS_VERSION_ACTUAL_EXPIRY,
|
||||
),
|
||||
_ => (ENV.REDIS_DEFAULT_EXPIRY, ENV.REDIS_ACTUAL_EXPIRY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,7 +740,7 @@ impl RedisConnection {
|
||||
cmd.arg(format!("{}_{}:{}", self.meta_namespace, namespace, id))
|
||||
.arg(data)
|
||||
.arg("EX")
|
||||
.arg(expiry.unwrap_or(DEFAULT_EXPIRY));
|
||||
.arg(expiry.unwrap_or(ENV.REDIS_DEFAULT_EXPIRY));
|
||||
redis_execute::<()>(&mut cmd, &mut self.connection).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ vars! {
|
||||
LABRINTH_ADMIN_KEY: String = "";
|
||||
LABRINTH_MEDAL_KEY: String = "";
|
||||
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String = "";
|
||||
LABRINTH_SUBSCRIPTIONS_KEY: String = "";
|
||||
RATE_LIMIT_IGNORE_KEY: String = "";
|
||||
DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth";
|
||||
REDIS_URL: String = "redis://localhost";
|
||||
@@ -294,6 +295,10 @@ vars! {
|
||||
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
|
||||
REDIS_MAX_CONNECTIONS: u32 = 10000u32;
|
||||
REDIS_MIN_CONNECTIONS: usize = 0usize;
|
||||
REDIS_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
|
||||
REDIS_ACTUAL_EXPIRY: i64 = 60 * 30;
|
||||
REDIS_VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
|
||||
REDIS_VERSION_ACTUAL_EXPIRY: i64 = 60 * 30;
|
||||
REDIS_ENCODING_FORMAT: crate::database::redis::EncodingFormat = crate::database::redis::EncodingFormat::Json;
|
||||
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
|
||||
REDIS_COMPRESSION_ALGORITHM: crate::database::redis::Codec = crate::database::redis::Codec::Lz4;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use self::payments::*;
|
||||
use self::update_subscriptions::*;
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::models::charge_item::DBCharge;
|
||||
use crate::database::models::ids::DBUserSubscriptionId;
|
||||
@@ -56,6 +57,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.service(charges)
|
||||
.service(credit)
|
||||
.service(active_servers)
|
||||
.service(update_many)
|
||||
.service(initiate_payment)
|
||||
.service(stripe_webhook)
|
||||
.service(refund_charge)
|
||||
@@ -63,7 +65,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
/// List products.
|
||||
/// List products.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -113,7 +115,7 @@ struct UserSubscriptionWithNextChargeTaxAmount {
|
||||
pub next_charge_tax_amount: Option<i64>,
|
||||
}
|
||||
|
||||
/// List subscriptions.
|
||||
/// List subscriptions.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -187,7 +189,7 @@ pub struct ChargeRefund {
|
||||
pub unprovision: Option<bool>,
|
||||
}
|
||||
|
||||
/// Refund a charge.
|
||||
/// Refund a charge.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -457,7 +459,7 @@ pub async fn refund_charge(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Reprocess tax for a charge.
|
||||
/// Reprocess tax for a charge.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -646,7 +648,7 @@ pub struct SubscriptionEditQuery {
|
||||
pub dry: Option<bool>,
|
||||
}
|
||||
|
||||
/// Update a subscription.
|
||||
/// Update a subscription.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1146,7 +1148,7 @@ pub async fn edit_subscription(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current customer.
|
||||
/// Get the current customer.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1190,7 +1192,7 @@ pub struct ChargesQuery {
|
||||
pub user_id: Option<ariadne::ids::UserId>,
|
||||
}
|
||||
|
||||
/// List payments.
|
||||
/// List payments.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1256,7 +1258,7 @@ pub async fn charges(
|
||||
))
|
||||
}
|
||||
|
||||
/// Start a payment method flow.
|
||||
/// Start a payment method flow.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1315,7 +1317,7 @@ pub struct EditPaymentMethod {
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
/// Update a payment method.
|
||||
/// Update a payment method.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1385,7 +1387,7 @@ pub async fn edit_payment_method(
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a payment method.
|
||||
/// Remove a payment method.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1474,7 +1476,7 @@ pub async fn remove_payment_method(
|
||||
}
|
||||
}
|
||||
|
||||
/// List payment methods.
|
||||
/// List payment methods.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1537,7 +1539,7 @@ struct ActiveServerResponse {
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// List active servers.
|
||||
/// List active servers.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1651,7 +1653,7 @@ pub struct PaymentRequest {
|
||||
pub metadata: Option<PaymentRequestMetadata>,
|
||||
}
|
||||
|
||||
/// Initiate a payment.
|
||||
/// Initiate a payment.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1721,7 +1723,7 @@ pub async fn initiate_payment(
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a Stripe webhook.
|
||||
/// Receive a Stripe webhook.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -1967,7 +1969,7 @@ pub async fn stripe_webhook(
|
||||
|
||||
if charge_status != ChargeStatus::Failed {
|
||||
subscription
|
||||
.upsert(transaction)
|
||||
.upsert(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -2009,7 +2011,7 @@ pub async fn stripe_webhook(
|
||||
};
|
||||
|
||||
if charge_status != ChargeStatus::Failed {
|
||||
charge.upsert(transaction).await?;
|
||||
charge.upsert(&mut *transaction).await?;
|
||||
}
|
||||
|
||||
(charge, price, product, subscription, new_region)
|
||||
@@ -2644,7 +2646,7 @@ pub enum CreditTarget {
|
||||
},
|
||||
}
|
||||
|
||||
/// Credit subscriptions.
|
||||
/// Credit subscriptions.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
@@ -2773,3 +2775,4 @@ pub async fn credit(
|
||||
}
|
||||
|
||||
pub mod payments;
|
||||
pub mod update_subscriptions;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use actix_web::{post, web};
|
||||
use ariadne::ids::UserId as Id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::user_subscription_item::DBUserSubscription;
|
||||
use crate::models::billing::SubscriptionMetadata;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::guards::subscriptions_key_guard;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateManySubscriptions {
|
||||
pub subscriptions: Vec<SubscriptionUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SubscriptionUpdate {
|
||||
pub target: SubscriptionTarget,
|
||||
pub update_region: Option<String>,
|
||||
pub ignore_if_missing: bool,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, ToSchema,
|
||||
)]
|
||||
pub enum SubscriptionTarget {
|
||||
Pyro { user_id: Id, server_id: Uuid },
|
||||
}
|
||||
|
||||
/// Update multiple managed subscriptions.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
request_body = UpdateManySubscriptions,
|
||||
responses((status = OK))
|
||||
)]
|
||||
#[post(
|
||||
"/subscriptions/manager/update_many",
|
||||
guard = "subscriptions_key_guard"
|
||||
)]
|
||||
pub async fn update_many(
|
||||
pool: web::Data<PgPool>,
|
||||
body: web::Json<UpdateManySubscriptions>,
|
||||
) -> Result<(), ApiError> {
|
||||
let UpdateManySubscriptions { subscriptions } = body.into_inner();
|
||||
|
||||
let mut txn = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
|
||||
// Only supports hosting subscriptions now, so gather the server IDs and
|
||||
// fetch the subscriptions from them
|
||||
let server_ids = subscriptions
|
||||
.iter()
|
||||
.map(|update| match update.target {
|
||||
SubscriptionTarget::Pyro { server_id, .. } => server_id.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let found_subscriptions =
|
||||
DBUserSubscription::get_many_by_server_ids(&server_ids, &mut txn)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch subscriptions to update")?;
|
||||
|
||||
let mut subscriptions_by_target =
|
||||
HashMap::<SubscriptionTarget, DBUserSubscription>::new();
|
||||
|
||||
// Creates a map of subscription "key" -> it's DB representation.
|
||||
for subscription in found_subscriptions {
|
||||
let Some(SubscriptionMetadata::Pyro { id, .. }) =
|
||||
subscription.metadata.as_ref()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(server_id) = Uuid::parse_str(id) else {
|
||||
continue;
|
||||
};
|
||||
let target = SubscriptionTarget::Pyro {
|
||||
user_id: subscription.user_id.into(),
|
||||
server_id,
|
||||
};
|
||||
|
||||
subscriptions_by_target.insert(target, subscription);
|
||||
}
|
||||
|
||||
for update in subscriptions {
|
||||
let Some(subscription) =
|
||||
subscriptions_by_target.get_mut(&update.target)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Update the subscription region
|
||||
if let Some(SubscriptionMetadata::Pyro { region, .. }) =
|
||||
subscription.metadata.as_mut()
|
||||
&& let Some(new_region) = update.update_region.clone()
|
||||
{
|
||||
*region = Some(new_region);
|
||||
}
|
||||
|
||||
subscription.upsert(&mut txn).await?;
|
||||
}
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -157,6 +157,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
billing::remove_payment_method,
|
||||
billing::payment_methods,
|
||||
billing::active_servers,
|
||||
billing::update_subscriptions::update_many,
|
||||
billing::initiate_payment,
|
||||
billing::stripe_webhook,
|
||||
billing::credit,
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::env::ENV;
|
||||
pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin";
|
||||
pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key";
|
||||
pub const EXTERNAL_NOTIFICATION_KEY_HEADER: &str = "External-Notification-Key";
|
||||
pub const SUBSCRIPTIONS_KEY_HEADER: &str = "Modrinth-Subscriptions-Key";
|
||||
|
||||
pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
||||
ctx.head()
|
||||
@@ -30,6 +31,20 @@ pub fn external_notification_key_guard(ctx: &GuardContext) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscriptions_key_guard(ctx: &GuardContext) -> bool {
|
||||
// Ensure the subs key is set and at least 32 characters
|
||||
if ENV.LABRINTH_SUBSCRIPTIONS_KEY.chars().count() < 32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.head()
|
||||
.headers()
|
||||
.get(SUBSCRIPTIONS_KEY_HEADER)
|
||||
.is_some_and(|it| {
|
||||
it.as_bytes() == ENV.LABRINTH_SUBSCRIPTIONS_KEY.as_bytes()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn internal_network_guard(ctx: &GuardContext) -> bool {
|
||||
ctx.head()
|
||||
.peer_addr
|
||||
|
||||
@@ -1105,8 +1105,15 @@ impl CachedEntry {
|
||||
.collect::<Vec<_>>()
|
||||
.chunks(MAX_REQUEST_SIZE)
|
||||
.map(|chunk| {
|
||||
serde_json::to_string(&chunk)
|
||||
.map(|keys| format!("{api_url}{url}{keys}"))
|
||||
serde_json::to_string(&chunk).map(|keys| {
|
||||
format!(
|
||||
"{api_url}{url}{}",
|
||||
url::form_urlencoded::byte_serialize(
|
||||
keys.as_bytes()
|
||||
)
|
||||
.collect::<String>()
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
|
||||
@@ -10,6 +10,45 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-07-22T10:46:03+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.18',
|
||||
body: `## Fixed
|
||||
- Fixed the browse content page when coming from an instance.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-21T23:25:29+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed license source nag showing on Data Pack projects.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-21T23:25:29+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.17',
|
||||
body: `## Fixed
|
||||
- Fixed skin selector serving old cached skin previews with bad UVs.
|
||||
- Fixed ads showing when sidebar is disabled.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-21T20:10:55+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.16',
|
||||
body: `## Fixed
|
||||
- Fixed skins having a massive UV offset.
|
||||
- Fixed deeplink issues for slugs containing + characters.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-21T19:36:51+00:00`,
|
||||
product: 'app',
|
||||
version: '0.15.15',
|
||||
body: `## Changed
|
||||
- Updated the "Minecraft required" popup to a new style.
|
||||
|
||||
## Fixed
|
||||
- Fixed devtools opening when ads are initialized.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-21T18:43:00+00:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -326,6 +326,8 @@ export const linksNags: Nag[] = [
|
||||
},
|
||||
status: 'required',
|
||||
shouldShow: (context: NagContext) => {
|
||||
if (context.projectV3.project_types.includes('datapack')) return false
|
||||
|
||||
const hasSourceUrl = !!context.project.source_url
|
||||
const hasAdditionalFiles = (context: NagContext) => {
|
||||
let hasAdditional = true
|
||||
|
||||
@@ -77,7 +77,11 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="item.type !== 'neutral'"></div>
|
||||
<div class="col-span-2 whitespace-pre-wrap text-sm text-primary">{{ item.text }}</div>
|
||||
<div
|
||||
class="col-span-2 whitespace-pre-wrap text-sm text-primary max-h-[80vh] overflow-y-auto"
|
||||
>
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div class="m-0 text-wrap text-xs font-medium text-secondary">
|
||||
|
||||
@@ -131,7 +131,7 @@ const SKIN_SIZE = 64
|
||||
const EARS_V0_MAGIC = 0x3f23d8
|
||||
const EARS_V1_MAGIC = 0xea2501
|
||||
const ALFALFA_MAGIC = 0xea1fa1fa
|
||||
const UV_TEXEL_INSET = 0.5
|
||||
const UV_TEXEL_INSET = 1 / 32
|
||||
|
||||
const EAR_MODES: EarMode[] = [
|
||||
'NONE',
|
||||
|
||||
@@ -4,7 +4,12 @@ import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import PageHeader from '#ui/components/base/page-header/index.vue'
|
||||
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
|
||||
import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue'
|
||||
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
|
||||
import { useServerImage } from '#ui/composables/use-server-image'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
@@ -46,34 +51,6 @@ const iconSrc = computed(() => {
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
||||
})
|
||||
|
||||
const leadingItems = computed(() => {
|
||||
const context = installContext.value
|
||||
if (!context) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'back',
|
||||
type: 'button' as const,
|
||||
icon: LeftArrowIcon,
|
||||
ariaLabel: context.backLabel,
|
||||
tooltip: context.backLabel,
|
||||
onClick: handleBack,
|
||||
},
|
||||
...(iconSrc.value
|
||||
? [
|
||||
{
|
||||
id: 'icon',
|
||||
type: 'avatar' as const,
|
||||
src: iconSrc.value,
|
||||
alt: context.name,
|
||||
avatarSize: '48px',
|
||||
class: 'shrink-0',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
const metadataItems = computed(() => {
|
||||
const context = installContext.value
|
||||
if (!context) return []
|
||||
@@ -162,13 +139,48 @@ async function handleSelectedProjectsLeaveResult(
|
||||
/>
|
||||
<PageHeader
|
||||
:title="installContext.name"
|
||||
:leading="leadingItems"
|
||||
:metadata="metadataItems"
|
||||
:divider="props.divider ?? false"
|
||||
:bottom-padding="props.bottomPadding ?? false"
|
||||
main-class="items-center"
|
||||
title-class="leading-8"
|
||||
truncate-title
|
||||
/>
|
||||
>
|
||||
<template #leading>
|
||||
<ButtonStyled circular size="large">
|
||||
<button
|
||||
v-tooltip="installContext.backLabel"
|
||||
type="button"
|
||||
:aria-label="installContext.backLabel"
|
||||
@click="handleBack"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Avatar
|
||||
v-if="iconSrc"
|
||||
:src="iconSrc"
|
||||
:alt="installContext.name"
|
||||
size="48px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="metadataItems.length" #metadata>
|
||||
<PageHeaderMetadata>
|
||||
<PageHeaderMetadataItem
|
||||
v-for="item in metadataItems"
|
||||
:key="item.id"
|
||||
:icon="item.icon"
|
||||
:icon-props="item.iconProps"
|
||||
:class="item.class"
|
||||
>
|
||||
{{ item.label }}
|
||||
</PageHeaderMetadataItem>
|
||||
</PageHeaderMetadata>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
||||
{{ installContext.warning }}
|
||||
</Admonition>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -13,8 +13,6 @@ const modelCache: Map<string, GLTF> = new Map()
|
||||
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
|
||||
const textureCache: Map<string, THREE.Texture> = new Map()
|
||||
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
|
||||
const SKIN_TEXTURE_SIZE = 64
|
||||
const SKIN_UV_INSET_VERSION = 1
|
||||
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
@@ -155,58 +153,12 @@ function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
|
||||
}
|
||||
}
|
||||
|
||||
function insetSkinGeometryUvs(geometry: THREE.BufferGeometry): void {
|
||||
if (geometry.userData.skinUvInsetVersion === SKIN_UV_INSET_VERSION) return
|
||||
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv || uv.itemSize < 2 || uv.count % 4 !== 0) return
|
||||
|
||||
for (let faceStart = 0; faceStart < uv.count; faceStart += 4) {
|
||||
let minU = Infinity
|
||||
let maxU = -Infinity
|
||||
let minV = Infinity
|
||||
let maxV = -Infinity
|
||||
|
||||
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
|
||||
const u = uv.getX(vertex)
|
||||
const v = uv.getY(vertex)
|
||||
minU = Math.min(minU, u)
|
||||
maxU = Math.max(maxU, u)
|
||||
minV = Math.min(minV, v)
|
||||
maxV = Math.max(maxV, v)
|
||||
}
|
||||
|
||||
const insetMinU = (Math.round(minU * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMaxU = (Math.round(maxU * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMinV = (Math.round(minV * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMaxV = (Math.round(maxV * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
|
||||
|
||||
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
|
||||
const u = uv.getX(vertex)
|
||||
const v = uv.getY(vertex)
|
||||
const insetU = u === minU ? insetMinU : u === maxU ? insetMaxU : u
|
||||
const insetV = v === minV ? insetMinV : v === maxV ? insetMaxV : v
|
||||
uv.setXY(vertex, insetU, insetV)
|
||||
}
|
||||
}
|
||||
|
||||
uv.needsUpdate = true
|
||||
geometry.userData.skinUvInsetVersion = SKIN_UV_INSET_VERSION
|
||||
}
|
||||
|
||||
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
const usesSkinTexture = materials.some(
|
||||
(mat) => mat instanceof THREE.MeshStandardMaterial && mat.name !== 'cape',
|
||||
)
|
||||
|
||||
if (usesSkinTexture) {
|
||||
insetSkinGeometryUvs(mesh.geometry)
|
||||
}
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
|
||||
Reference in New Issue
Block a user